Skip to content

How to Create a JavaScript Fetch Wrapper for API Calls

The Problem

I kept making the same mistakes with the fetch API. Every POST request, I forgot the Content-Type header. Every response, I forgot to call .json(). And every error handler, I struggled to get the response body.

Here’s what my code looked like:

raw_fetch.js
// POST request - boilerplate everywhere
async function createUser(userData) {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // Forgot this once - got 415 error
},
body: JSON.stringify(userData), // Forgot stringify once - sent "[object Object]"
});
if (!response.ok) {
// How do I get the error message from body?
const error = await response.json(); // Might fail if body is empty
throw new Error(error.message || 'Unknown error');
}
return response.json(); // Forgot this once - returned Response object
}

This boilerplate repeated across every API call. I thought: “Should I just use axios?”

Why People Use Axios

I found a Reddit thread asking: “What do people use axios for?”

The top comment (235 points) said:

“your wrapper covers like 80% of what most people need honestly”

Another comment pointed out:

“one thing that does bite people with raw fetch is forgetting the content-type header or the json stringify dance”

So the real value of axios isn’t magic - it’s just handling these common pitfalls automatically. I realized I could build my own wrapper that does the same thing.

The Common Mistakes I Made

Mistake 1: Forgetting Content-Type Header

no_header.js
// WRONG: No Content-Type header
await fetch('/api/users', {
method: 'POST',
body: { name: 'John' } // Browser sends as text, server expects JSON
});
// Server responds:
// 415 Unsupported Media Type

The server rejected my request because it didn’t know the body was JSON.

Mistake 2: Not Stringifying JSON Body

no_stringify.js
// WRONG: Object passed directly
await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { name: 'John' } // fetch expects string, not object
});
// What gets sent:
// "[object Object]" - server can't parse this

I forgot that fetch expects a string body, not a JavaScript object.

Mistake 3: Not Handling Empty Response Bodies

empty_body.js
// WRONG: Assumes response has JSON body
const response = await fetch('/api/users/1', { method: 'DELETE' });
const data = await response.json(); // Fails if body is empty!
// SyntaxError: Unexpected end of JSON input

DELETE endpoints often return empty bodies. Calling .json() on an empty response throws an error.

Mistake 4: Inconsistent Error Handling

bad_error.js
// WRONG: No structured error object
if (!response.ok) {
throw new Error('Request failed'); // Lost status code and response body
}
// Can't tell what went wrong:
// - Was it 401 (unauthorized)?
// - Was it 404 (not found)?
// - What was the error message from server?

I threw generic errors without the useful information from the response.

Building the Wrapper

I started with a simple function:

first_wrapper.js
async function api(path, opts = {}) {
const res = await fetch(path, opts);
if (!res.ok) {
throw { status: res.status, data: await res.json() };
}
return res.json();
}

But this didn’t solve my problems. I still needed to pass headers and stringify the body manually.

Attempt 2: Auto-set Content-Type

I added automatic Content-Type detection:

auto_content_type.js
async function api(path, opts = {}) {
const headers = opts.headers || {};
// Auto-set Content-Type for POST, PUT, PATCH
if (['POST', 'PUT', 'PATCH'].includes(opts.method?.toUpperCase())) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(path, {
...opts,
headers,
});
if (!res.ok) {
throw { status: res.status, data: await res.json() };
}
return res.json();
}

Now mutation requests automatically get the right Content-Type. But I still forgot to stringify the body.

Attempt 3: Auto-stringify Body

auto_stringify.js
async function api(path, opts = {}) {
const headers = opts.headers || {};
if (['POST', 'PUT', 'PATCH'].includes(opts.method?.toUpperCase())) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(path, {
...opts,
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (!res.ok) {
throw { status: res.status, data: await res.json() };
}
return res.json();
}

Now I pass objects directly - the wrapper handles stringify. But empty response bodies still crashed my code.

Final Version: Handle All Edge Cases

api_wrapper.js
async function api(path, opts = {}) {
// Initialize headers if not provided
const headers = opts.headers || {};
// Auto-set Content-Type for mutation requests
if (['POST', 'PUT', 'PATCH'].includes(opts.method?.toUpperCase())) {
headers['Content-Type'] = 'application/json';
}
// Make the request with auto-stringify
const res = await fetch(path, {
...opts,
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
// Handle errors with structured response
if (!res.ok) {
throw {
status: res.status,
data: await res.json().catch(() => null) // Handle empty/invalid JSON
};
}
// Parse response, handle empty bodies
return res.json().catch(() => null);
}
// Add convenience method shortcuts
['get', 'post', 'put', 'patch', 'delete'].forEach(method => {
api[method] = (path, opts = {}) => api(path, { ...opts, method: method.toUpperCase() });
});

The key changes:

  1. await res.json().catch(() => null) - handles empty or malformed JSON
  2. Method shortcuts (api.get, api.post) - axios-like convenience
  3. Structured error object - contains both status and response body

Usage Examples

Now my API calls are clean:

usage.js
// GET request - no boilerplate
const users = await api.get('/api/users');
// POST request - auto Content-Type, auto stringify
const newUser = await api.post('/api/users', {
body: { name: 'John', email: '[email protected]' }
});
// PUT request with custom headers
const updated = await api.put('/api/users/1', {
body: { name: 'Jane' },
headers: { 'Authorization': 'Bearer token' }
});
// DELETE - handles empty response body
await api.delete('/api/users/1'); // Returns null, no crash
// Error handling with structured info
try {
const data = await api.get('/api/protected');
} catch (error) {
console.log(error.status); // 401
console.log(error.data); // { message: 'Unauthorized' }
}

What This Wrapper Doesn’t Do

My wrapper covers 80% of cases. It doesn’t handle:

FeatureWrapperaxios
Request/response interceptorsNoYes
Automatic retriesNoNo (needs plugin)
Request timeoutNoYes
Progress monitoringNoYes
CSRF/XSRF protectionNoYes
CancellationManualYes (AbortController)

For most projects, I don’t need these. If I do, I can add them incrementally or switch to axios.

Comparison: Raw Fetch vs Wrapper vs axios

comparison.txt
Raw fetch:
- Manual Content-Type headers
- Manual JSON.stringify
- Manual response parsing
- Manual error handling
- No method shortcuts
My wrapper:
+ Auto Content-Type for mutations
+ Auto JSON.stringify
+ Auto response parsing with fallback
+ Structured error objects
+ Method shortcuts (api.get, api.post)
- No interceptors, timeout, retries
axios:
+ All wrapper features
+ Request/response interceptors
+ Timeout support
+ Automatic transforms
+ XSRF protection
+ Broader browser support (old IE)
- Extra dependency (~13KB)
- Different error structure

When to Use This Wrapper

Use a custom wrapper when:

  • You want zero dependencies
  • Your API calls are straightforward (GET, POST, PUT, DELETE)
  • You don’t need interceptors or complex transforms
  • You want to understand what’s happening under the hood

Use axios when:

  • You need request/response interceptors
  • You need timeout handling
  • You’re working with legacy browsers
  • Your team already uses axios

Adding TypeScript Support

I added types for better IDE support:

api_types.ts
interface ApiError {
status: number;
data: unknown | null;
}
interface ApiOptions {
method?: string;
body?: unknown;
headers?: Record<string, string>;
}
async function api<T = unknown>(
path: string,
opts: ApiOptions = {}
): Promise<T | null> {
const headers = opts.headers || {};
if (['POST', 'PUT', 'PATCH'].includes(opts.method?.toUpperCase())) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(path, {
...opts,
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (!res.ok) {
throw {
status: res.status,
data: await res.json().catch(() => null)
} as ApiError;
}
return res.json().catch(() => null) as T | null;
}
// Type-safe method shortcuts
['get', 'post', 'put', 'patch', 'delete'].forEach(method => {
api[method] = <T = unknown>(path: string, opts: ApiOptions = {}) =>
api<T>(path, { ...opts, method: method.toUpperCase() });
});

Now I get type hints:

typed_usage.ts
interface User {
id: number;
name: string;
email: string;
}
// Type inferred from return type
const users = await api.get<User[]>('/api/users');
// users: User[] | null
// Type-safe POST
const newUser = await api.post<User>('/api/users', {
body: { name: 'John', email: '[email protected]' }
});
// newUser: User | null

Summary

A custom fetch wrapper gives you axios-like convenience without the dependency. My wrapper:

  1. Auto-sets Content-Type: application/json for POST, PUT, PATCH
  2. Auto-stringifies request bodies
  3. Parses JSON responses with fallback for empty bodies
  4. Throws structured error objects (status + data)
  5. Provides method shortcuts (api.get, api.post, etc.)

This covers the “JSON stringify dance” and Content-Type headers that trip up raw fetch users. The Reddit commenter was right: a simple wrapper handles 80% of what most people need.

Start simple. Add interceptors, retries, or timeout handling only when you actually need them.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments