Axios Essentials
Axios Essentials Axios is a promise-based HTTP client for the browser and Node.js. Compared to the native `fetch`, it auto-parses JSON, throws on non-2xx status…
Axios Essentials
Axios is a promise-based HTTP client for the browser and Node.js. Compared to the native `fetch`, it auto-parses JSON, throws on non-2xx status codes by default, supports request/response interceptors, has built-in request cancellation, and normalizes behavior across browsers and Node — which is why it's still the default choice in most React/Vue apps even though `fetch` has closed much of the gap.
Instances & Base Config
Never call the default `axios` export directly across a real app — create a configured instance per API you talk to. This centralizes the base URL, default headers, and timeout, and gives you a single place to attach interceptors.
// api/client.js
import axios from 'axios'
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL ?? 'https://api.example.com',
timeout: 10000, // ms — fail fast instead of hanging forever
headers: {
'Content-Type': 'application/json',
},
// withCredentials: true, // send cookies cross-origin (needs matching CORS config server-side)
})
// Per-request overrides merge with instance defaults
api.get('/users', { params: { page: 2, limit: 20 } })
api.get('/users/42', { timeout: 5000 })
// Query params: axios serializes the params object into a query string
// GET /users?page=2&limit=20
// Multiple instances for multiple backends
export const authApi = axios.create({ baseURL: 'https://auth.example.com' })
export const analyticsApi = axios.create({
baseURL: 'https://analytics.example.com',
headers: { 'X-Client': 'web' },
})Interceptors: Auth Headers & Token Refresh
Request interceptors run before a request is sent (attach auth headers, log, mutate config). Response interceptors run on every response, including errors — the classic use case is transparently refreshing an expired access token and retrying the original request exactly once, so callers never see the 401.
import { api } from './client'
import { getAccessToken, refreshAccessToken, logout } from './auth'
// Request interceptor — attach the current token to every call
api.interceptors.request.use(
(config) => {
const token = getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
},
(error) => Promise.reject(error),
)
// Response interceptor — refresh once on 401, then retry the original request
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config
if (error.response?.status === 401 && !original._retry) {
original._retry = true // guard against infinite refresh loops
try {
const newToken = await refreshAccessToken()
original.headers.Authorization = `Bearer ${newToken}`
return api(original) // re-run the original request with the new token
} catch (refreshError) {
logout()
return Promise.reject(refreshError)
}
}
return Promise.reject(error)
},
)
// Interceptors can be removed if you need to (e.g. in tests)
const id = api.interceptors.request.use((c) => c)
api.interceptors.request.eject(id)Error Handling
Axios rejects the promise for any non-2xx response — you don't need to manually check `response.ok` like with `fetch`. The rejected error carries three distinct shapes depending on what failed: a server responded with an error status, the request was sent but no response came back, or the request never got sent.
import axios from 'axios'
import { api } from './client'
async function createUser(payload) {
try {
const { data } = await api.post('/users', payload)
return data
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response) {
// Server responded with a status outside 2xx
console.error('Server error:', error.response.status, error.response.data)
if (error.response.status === 422) {
throw new ValidationError(error.response.data.errors)
}
} else if (error.request) {
// Request was made, no response received (network down, timeout, CORS)
console.error('No response received:', error.message)
} else {
// Something went wrong setting up the request itself
console.error('Request setup error:', error.message)
}
}
throw error
}
}
// Treat specific non-2xx codes as success if your API uses them intentionally
api.get('/status', {
validateStatus: (status) => status < 500, // don't reject on 4xx
})Cancellation with AbortController
Modern axios (v1+) uses the standard `AbortController` for cancellation instead of the deprecated `CancelToken` API. This matters most for search-as-you-type and any effect that can re-fire before the previous request finishes — without cancellation, a slow earlier response can overwrite a newer one (a race condition).
import { useEffect, useState } from 'react'
import { api } from './client'
function useSearch(query) {
const [results, setResults] = useState([])
useEffect(() => {
if (!query) return
const controller = new AbortController()
api
.get('/search', { params: { q: query }, signal: controller.signal })
.then((res) => setResults(res.data))
.catch((err) => {
if (axios.isCancel(err) || err.name === 'CanceledError') return // expected, ignore
console.error(err)
})
return () => controller.abort() // cancel the in-flight request on re-run/unmount
}, [query])
return results
}
// Manual timeout-driven cancellation
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
api.get('/slow-endpoint', { signal: controller.signal }).finally(() => clearTimeout(timeoutId))Transforming Requests & Responses
`transformRequest`/`transformResponse` run before axios serializes a request body or after it parses a response, letting you normalize payloads (snake_case ↔ camelCase, date parsing) in one place instead of at every call site.
import axios from 'axios'
import camelcaseKeys from 'camelcase-keys'
import snakecaseKeys from 'snakecase-keys'
export const api = axios.create({
baseURL: 'https://api.example.com',
transformRequest: [
(data) => (data ? snakecaseKeys(data, { deep: true }) : data),
...axios.defaults.transformRequest, // keep default JSON.stringify step
],
transformResponse: [
...axios.defaults.transformResponse,
(data) => (data && typeof data === 'object' ? camelcaseKeys(data, { deep: true }) : data),
],
})
// Concurrent requests
const [users, posts] = await Promise.all([api.get('/users'), api.get('/posts')])
// File upload with progress
api.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (event) => {
const percent = Math.round((event.loaded * 100) / (event.total ?? 1))
console.log(`Upload: ${percent}%`)
},
})Common Gotchas
Forgetting axios throws on error status — unlike `fetch`, a 404/500 response rejects the promise; wrap calls in try/catch instead of checking `response.ok`.
Using the deprecated CancelToken API — v1+ uses the standard `AbortController`/`signal`; `CancelToken.source()` still works but is legacy.
Retrying inside a response interceptor without a guard flag — a 401 handler that always retries without checking `config._retry` can loop forever if the refresh itself keeps failing with 401.
Setting `Content-Type: multipart/form-data` manually with FormData — the browser needs to set the boundary itself; let axios/the browser generate this header for `FormData` bodies rather than hardcoding it.
No default timeout — axios requests hang indefinitely unless you set `timeout` on the instance; a slow backend can otherwise stall your UI with no feedback.
Calling the default `axios` export everywhere — skips the benefit of instances entirely; you end up repeating baseURL/headers/interceptor logic at every call site.