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:
// POST request - boilerplate everywhereasync 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
// WRONG: No Content-Type headerawait fetch('/api/users', { method: 'POST', body: { name: 'John' } // Browser sends as text, server expects JSON});
// Server responds:// 415 Unsupported Media TypeThe server rejected my request because it didn’t know the body was JSON.
Mistake 2: Not Stringifying JSON Body
// WRONG: Object passed directlyawait 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 thisI forgot that fetch expects a string body, not a JavaScript object.
Mistake 3: Not Handling Empty Response Bodies
// WRONG: Assumes response has JSON bodyconst response = await fetch('/api/users/1', { method: 'DELETE' });const data = await response.json(); // Fails if body is empty!
// SyntaxError: Unexpected end of JSON inputDELETE endpoints often return empty bodies. Calling .json() on an empty response throws an error.
Mistake 4: Inconsistent Error Handling
// WRONG: No structured error objectif (!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:
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:
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
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
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:
await res.json().catch(() => null)- handles empty or malformed JSON- Method shortcuts (
api.get,api.post) - axios-like convenience - Structured error object - contains both status and response body
Usage Examples
Now my API calls are clean:
// GET request - no boilerplateconst users = await api.get('/api/users');
// POST request - auto Content-Type, auto stringifyconst newUser = await api.post('/api/users', {});
// PUT request with custom headersconst updated = await api.put('/api/users/1', { body: { name: 'Jane' }, headers: { 'Authorization': 'Bearer token' }});
// DELETE - handles empty response bodyawait api.delete('/api/users/1'); // Returns null, no crash
// Error handling with structured infotry { 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:
| Feature | Wrapper | axios |
|---|---|---|
| Request/response interceptors | No | Yes |
| Automatic retries | No | No (needs plugin) |
| Request timeout | No | Yes |
| Progress monitoring | No | Yes |
| CSRF/XSRF protection | No | Yes |
| Cancellation | Manual | Yes (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
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 structureWhen 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:
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:
interface User { id: number; name: string; email: string;}
// Type inferred from return typeconst users = await api.get<User[]>('/api/users');// users: User[] | null
// Type-safe POSTconst newUser = await api.post<User>('/api/users', {});// newUser: User | nullSummary
A custom fetch wrapper gives you axios-like convenience without the dependency. My wrapper:
- Auto-sets
Content-Type: application/jsonfor POST, PUT, PATCH - Auto-stringifies request bodies
- Parses JSON responses with fallback for empty bodies
- Throws structured error objects (status + data)
- 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