Replace Axios with a Simple Custom Fetch Wrapper (Production-Ready Guide)
Originally published March 30th, 2020 — Updated April 3rd, 2026 — 6 min read
3 Apr 2026

Originally published March 30th, 2020 — Updated April 3rd, 2026 — 6 min read
By K. Mitch Hodge

I remember being with Matt Zabriskie when he first talked about building a vanilla JavaScript version of AngularJS's $http service. That idea became axios.
At the time, raw XMLHttpRequest was clunky and awkward. Axios made request/response handling dramatically better, and it worked in both Node.js and browsers. It solved a real pain.
Fast-forward a few years: browsers now give us fetch, a native promise-based API with broad support. For many frontend apps, that opens up a practical option:
- Keep axios if it still serves your needs, or
- Replace it with a small wrapper around
fetchthat does exactly what your app needs.
This guide walks through building that wrapper from basic to production-ready.
Why replace axios?
A simple custom wrapper can be a good fit when you want:
- Less API surface area to learn and maintain
- Smaller browser bundle footprint
- Fewer dependency updates and breaking-change surprises
- Faster debugging and fixes (because the wrapper is your code)
- A clearer mental model for network behavior
If you rely heavily on axios interceptors, cancellation APIs, or Node-specific behavior, axios may still be the better choice. But for many browser apps, a focused fetch client is enough.
Step 1: the smallest useful client
function client(endpoint: string, customConfig: RequestInit = {}) {
const config: RequestInit = {
method: 'GET',
...customConfig,
}
return window
.fetch(`${import.meta.env.VITE_API_URL}/${endpoint}`, config)
.then((response) => response.json())
}
Usage:
client(`books?query=${encodeURIComponent(query)}`).then(
(data) => {
console.log('Books:', data.books)
},
(error) => {
console.error('Request failed:', error)
},
)
Step 2: reject non-2xx responses
By default, fetch only rejects for network failures, not for HTTP errors like 400 or 500.
function client(endpoint: string, customConfig: RequestInit = {}) {
const config: RequestInit = {
method: 'GET',
...customConfig,
}
return window
.fetch(`${import.meta.env.VITE_API_URL}/${endpoint}`, config)
.then(async (response) => {
if (response.ok) {
return await response.json()
}
const errorMessage = await response.text()
return Promise.reject(new Error(errorMessage || 'Request failed'))
})
}
Now your catch and rejected promise handlers run for non-OK status codes.
Step 3: support JSON request bodies
function client(endpoint: string, { body, ...customConfig }: RequestInit & { body?: unknown } = {}) {
const headers = { 'Content-Type': 'application/json' }
const config: RequestInit = {
method: body ? 'POST' : 'GET',
...customConfig,
headers: {
...headers,
...(customConfig.headers || {}),
},
}
if (body !== undefined) {
config.body = JSON.stringify(body)
}
return window
.fetch(`${import.meta.env.VITE_API_URL}/${endpoint}`, config)
.then(async (response) => {
if (response.ok) {
return await response.json()
}
const errorMessage = await response.text()
return Promise.reject(new Error(errorMessage || 'Request failed'))
})
}
Usage:
client('login', { body: { username, password } }).then(
(data) => {
console.log('Logged in user:', data)
},
(error) => {
console.error('Login failed:', error)
},
)
Step 4: include auth token automatically
const localStorageKey = '__bookshelf_token__'
function client(endpoint: string, { body, ...customConfig }: RequestInit & { body?: unknown } = {}) {
const token = window.localStorage.getItem(localStorageKey)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
const config: RequestInit = {
method: body ? 'POST' : 'GET',
...customConfig,
headers: {
...headers,
...(customConfig.headers || {}),
},
}
if (body !== undefined) {
config.body = JSON.stringify(body)
}
return window
.fetch(`${import.meta.env.VITE_API_URL}/${endpoint}`, config)
.then(async (response) => {
if (response.ok) {
return await response.json()
}
const errorMessage = await response.text()
return Promise.reject(new Error(errorMessage || 'Request failed'))
})
}
Step 5: handle expired sessions globally (401)
const localStorageKey = '__bookshelf_token__'
function logout() {
window.localStorage.removeItem(localStorageKey)
}
function client(endpoint: string, { body, ...customConfig }: RequestInit & { body?: unknown } = {}) {
const token = window.localStorage.getItem(localStorageKey)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
const config: RequestInit = {
method: body ? 'POST' : 'GET',
...customConfig,
headers: {
...headers,
...(customConfig.headers || {}),
},
}
if (body !== undefined) {
config.body = JSON.stringify(body)
}
return window
.fetch(`${import.meta.env.VITE_API_URL}/${endpoint}`, config)
.then(async (response) => {
if (response.status === 401) {
logout()
window.location.assign(window.location.href)
return
}
if (response.ok) {
return await response.json()
}
const errorMessage = await response.text()
return Promise.reject(new Error(errorMessage || 'Request failed'))
})
}
At this point, you already have most behavior many apps use axios for.
A production-ready version
The wrapper below adds practical improvements:
- Typed error object with HTTP status and response payload
- Optional query params
- Safe parsing for JSON and non-JSON responses
204 No Contenthandling- Timeout with
AbortController - Per-request custom headers and method
type QueryValue = string | number | boolean | null | undefined
type HttpClientConfig = {
baseUrl: string
getToken?: () => string | null
onUnauthorized?: () => void
defaultTimeoutMs?: number
}
type ClientOptions = Omit<RequestInit, 'body'> & {
body?: unknown
query?: Record<string, QueryValue>
timeoutMs?: number
}
export class HttpError extends Error {
status: number
data: unknown
constructor(message: string, status: number, data: unknown) {
super(message)
this.name = 'HttpError'
this.status = status
this.data = data
}
}
function withQuery(endpoint: string, query?: Record<string, QueryValue>) {
if (!query) return endpoint
const params = new URLSearchParams()
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue
params.set(key, String(value))
}
const queryString = params.toString()
return queryString ? `${endpoint}?${queryString}` : endpoint
}
async function parseResponse(response: Response) {
if (response.status === 204) return null
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
return response.json()
}
return response.text()
}
export function createHttpClient({
baseUrl,
getToken,
onUnauthorized,
defaultTimeoutMs = 15000,
}: HttpClientConfig) {
return async function client<T = unknown>(endpoint: string, options: ClientOptions = {}): Promise<T> {
const { body, query, headers, timeoutMs = defaultTimeoutMs, ...customConfig } = options
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs)
try {
const token = getToken?.()
const mergedHeaders: HeadersInit = {
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(headers || {}),
}
const response = await window.fetch(`${baseUrl}/${withQuery(endpoint, query)}`, {
method: body !== undefined ? 'POST' : 'GET',
...customConfig,
headers: mergedHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
const data = await parseResponse(response)
if (response.status === 401) {
onUnauthorized?.()
}
if (!response.ok) {
const message = typeof data === 'string' && data ? data : `Request failed with ${response.status}`
throw new HttpError(message, response.status, data)
}
return data as T
} finally {
window.clearTimeout(timeoutId)
}
}
}
Example setup:
const tokenKey = '__bookshelf_token__'
export const client = createHttpClient({
baseUrl: import.meta.env.VITE_API_URL,
getToken: () => localStorage.getItem(tokenKey),
onUnauthorized: () => {
localStorage.removeItem(tokenKey)
window.location.assign('/login')
},
})
Domain-specific API modules
Keep request details close to the domain:
import { client } from './api-client'
export function createListItem(listItemData: unknown) {
return client('list-items', { body: listItemData })
}
export function readListItems() {
return client('list-items')
}
export function updateListItem(listItemId: string, updates: unknown) {
return client(`list-items/${listItemId}`, {
method: 'PUT',
body: updates,
})
}
export function removeListItem(listItemId: string) {
return client(`list-items/${listItemId}`, { method: 'DELETE' })
}
This pattern keeps your UI components clean and avoids scattered URL strings.
Migration checklist (axios -> fetch wrapper)
- Implement one central client first
- Move one feature/module at a time
- Standardize error handling in one place
- Add auth and
401behavior centrally - Add timeout/abort behavior to avoid hanging requests
- Keep response parsing consistent (
json,text,204) - Remove axios only after all call sites are migrated
Conclusion
Axios remains a great library, especially for Node.js and teams that rely on its full feature set.
But in browser-heavy apps, a focused fetch wrapper often wins: fewer dependencies, lower complexity, and behavior tailored to your product. You can still build interceptor-like behavior; you just do it with plain functions you own.
If your API layer has been feeling too abstract or too heavy, this is a strong simplification move.