TypeScript

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

Replace Axios with a Simple Custom Fetch Wrapper (Production-Ready Guide)

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

Replace axios with a custom fetch wrapper cover image

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 fetch that 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

Ts
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:

Ts
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.

Ts
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

Ts
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:

Ts
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

Ts
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)

Ts
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 Content handling
  • Timeout with AbortController
  • Per-request custom headers and method
Ts
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:

Ts
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:

Ts
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 401 behavior 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.

Keep reading