Centralize HTTP logic with Axios instances and interceptors
Stop repeating base URLs, timeouts, and auth headers across API files. Learn how one Axios instance with interceptors standardizes requests and errors.
25 Nov 2025, 09:46 UTC

The problem: the same axios call in every file
In a growing frontend codebase, API logic spreads across modules. One file fetches users, another posts events, a third handles uploads. Each file starts with the same boilerplate:
import axios from 'axios';
const response = await axios.post('https://api.example.com/v1/users', user, {
headers: { Authorization: `Bearer ${token}` },
timeout: 5000,
});
When the API base URL changes, you edit it in ten places. When a token expires, every module handles 401 differently—one redirects to login, another shows a toast, a third ignores it. That repetition makes the client harder to secure and harder to debug.
Thesis: one configured instance, shared everywhere
axios.create() returns a new Axios instance with its own defaults, such as baseURL, timeout, default headers, and withCredentials. Because interceptors are scoped to the instance, modules can import the same client without mutating the global axios singleton.
The useful takeaway: centralize transport concerns in one file, then let feature modules call endpoints with relative paths and consistent error shapes.
Worked example: create and export a client
Create src/api/client.js:
import axios from 'axios';
const API_BASE_URL = process.env.VITE_API_BASE_URL ?? 'https://api.example.com';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 8000,
headers: {
'Content-Type': 'application/json',
},
});
Axios serializes plain JavaScript objects to JSON automatically when the request Content-Type is application/json, and it parses JSON response bodies by default.
Attach a request interceptor for auth
Request interceptors run before a request is sent. Use one to attach an authentication token from your storage layer:
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
}, (error) => Promise.reject(error));
Do not mutate config in ways that leak state between concurrent requests. If you need per-request headers, set them on the individual call instead of rewriting shared properties.
Attach a response interceptor for normalization
Response interceptors run before .then or catch and can transform both success and error responses:
apiClient.interceptors.response.use(
(response) => response,
(error) => {
const status = error.response?.status;
const message = error.response?.data?.message ?? error.message;
const normalized = {
status,
message,
isAuthError: status === 401,
};
return Promise.reject(normalized);
}
);
Call sites can still inspect error.status for status-specific behavior, such as retrying a 503 or showing a validation list for 422.
Trade-off: global error handling can hide local nuance
A response interceptor that rejects every non-2xx response centralizes logging, but it can also swallow status-specific handling that individual call sites need. An endpoint that intentionally returns 409 on conflict might require the caller to decide whether to retry or prompt the user.
Practical check: after adding the interceptor, open your browser DevTools Network tab, trigger a failing request, and confirm the rejection reaches your catch block with the normalized shape you expect. If a call site needs the raw response, consider bypassing the instance for that call or passing a flag through config.
Verify before you rely on it
- Check the installed major version in your project terminal:
node -e "console.log(require('axios/package.json').version)"
Run this in your project root as a normal user. No special permissions are required. Review the Axios changelog for that major version before adopting instance defaults or interceptor patterns.
- Unit-test the interceptor by stubbing the HTTP adapter and asserting that
Authorizationis added andbaseURLis prepended to relative URLs.
Risk: changing a shared client affects every module that imports it. Revert the file from version control if a regression appears; there is no runtime rollback because this is a code change, not a database migration.
Closing
Start with one instance, one base URL, and one error normalizer. Add interceptors only for concerns that truly apply to every request—auth headers and error shape are good candidates; business-logic retries usually belong closer to the call site. Verify with a failing request in DevTools, then delete the duplicated axios calls from your feature files.