Updated June 2026. Tested on Axios 1.x. Part of the Techalyst JavaScript series.
Axios is the workhorse HTTP client for the browser. You can fire a one-off axios.get(...) anywhere, but the moment an app has more than a couple of requests you want a configured instance plus interceptors, so auth, base URLs, and error handling live in one place instead of being copy-pasted everywhere. Here is how to set that up well.
A configured instance
Create a named instance with axios.create and give it sensible defaults. Every request through it inherits these, and any request can override them.
// services/api.js
import axios from 'axios'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 15000, // Axios has NO default timeout; always set one
withCredentials: true, // send cookies, needed for Laravel Sanctum SPA auth
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})
Two of these earn special mention. timeout matters because without it a dead connection hangs forever, a timed-out request rejects with error.code === 'ECONNABORTED' and no response. And withCredentials: true is required for cookie-based auth, but only set it on instances talking to your own backend, never on one calling a third-party API, or you leak your cookies to them. A nice pattern is one instance per backend (api, cdnApi, publicApi), each with its own config and interceptors.
HTTP methods and where data goes
The methods mirror REST, and the one thing to memorise is where the payload lives.
await api.get('/users', { params: { role: 'admin', page: 2 } }) // query string
await api.post('/users', { name: 'Aisha' }) // body (2nd arg)
await api.put(`/users/${id}`, fullUser) // body, full replace
await api.patch(`/users/${id}`, { role: 'editor' }) // body, partial
await api.delete(`/users/${id}`) // no body
await api.delete('/users', { data: { ids: [1, 2] } }) // body via config.data
GET and DELETE put data in params (the query string); POST, PUT, and PATCH take the body as the second argument with config as the third. The classic gotcha: a DELETE with a body needs config.data, not a positional argument. Prefer PATCH over PUT for partial edits, since PUT expects the whole resource.
Request interceptors
A request interceptor runs before every request leaves. The canonical use is attaching an auth token, so no individual call has to remember it.
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token') // or a Pinia store
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
Anything that should apply to every outgoing request (app version headers, a request id, a client fingerprint) belongs here.
Response interceptors
A response interceptor runs on every response, with separate success and error callbacks. Two jobs dominate: unwrapping the payload and centralising error handling.
api.interceptors.response.use(
(response) => response.data, // unwrap so callers get data directly
(error) => {
// central error handling, see below
return Promise.reject(error)
},
)
Returning response.data from the success path means your components write const users = await api.get('/users') instead of (await ...).data every time. If some side effect must run on both success and failure (reading a refreshed-token header, say), pull it into a shared function and call it from both callbacks.
The three shapes of an error
Centralising errors only works if you know what an Axios error can be. Never assume error.response exists, that missing check is the most common Axios bug.
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.name === 'CanceledError') return Promise.reject(error) // 1. cancelled on purpose
if (!error.response) { // 2. no response at all
const msg = error.code === 'ECONNABORTED' ? 'Request timed out.' : 'Network error.'
toast.error(msg)
return Promise.reject(error)
}
const status = error.response.status // 3. server responded non-2xx
if (status === 401) redirectToLogin()
if (status === 403) toast.error('You do not have permission.')
if (status === 422) return Promise.reject(error) // let the form handle validation
if (status >= 500) toast.error('Server error. Please try again.')
return Promise.reject(error)
},
)
The three cases are: an intentional cancellation (not a real error), a request that got no response (network down, timeout, CORS, so error.response is undefined), and a server response with a non-2xx status (where error.response.status tells you what happened). Handling each deliberately is what makes the whole app's error behaviour consistent.
One status to leave alone: 422. Laravel returns validation errors as { message, errors: { field: [...] } }, and those belong to the form that made the request, so reject and let the component read error.response.data.errors.
Wrapping up
Build a configured Axios instance with a baseURL and a timeout (always set the timeout), and reserve withCredentials for your own backend. Remember where each method puts its data, params for GET/DELETE, body for POST/PUT/PATCH. Use a request interceptor to attach tokens and a response interceptor to unwrap data and handle errors in one place, branching on the three error shapes: cancelled, no response, and non-2xx. That setup scales from a handful of calls to an entire app.
More in the series: advanced Axios: cancellation, caching and offline and JavaScript promises and async/await. Questions welcome below.
All comments ()
No comments yet
Be the first to leave a comment on this post.