Axios API Service Layer Best Practices
Structuring an API service layer with Axios streamlines HTTP communication, improves maintainability, and prevents code duplication across your application. This guide outlines the essential architectural patterns and best practices for creating a robust API client, including custom instance creation, interceptor pipelines for authentication and error handling, modular endpoint organization, and request cancellation strategies.
1. Create a Centralized Axios Instance
Avoid importing the default Axios instance directly into UI components. Instead, create a dedicated instance with predefined default configurations such as base URLs, headers, and request timeouts.
// api/client.js
import axios from 'axios';
export const apiClient = axios.create({
baseURL: process.env.API_BASE_URL || 'https://api.example.com/v1',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});Using a centralized instance ensures that any global change—such as updating headers or switching environments—only needs to happen in one location.
2. Implement Request Interceptors for Authentication
Use request interceptors to automatically attach authorization tokens (like JWTs) to outgoing requests before they leave the browser or server.
// api/interceptors.js
import { apiClient } from './client';
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);3. Implement Response Interceptors for Global Error Handling
Response interceptors allow you to catch errors, transform response
payloads, and handle common HTTP status codes (such as
401 Unauthorized or 403 Forbidden) in a single
place.
apiClient.interceptors.response.use(
(response) => response.data, // Unwraps the nested Axios data object
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Handle token refresh logic here
const newToken = await refreshToken();
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
// Redirect to login or clear auth state
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(normalizeError(error));
}
);
function normalizeError(error) {
return {
message: error.response?.data?.message || error.message || 'An unexpected error occurred',
status: error.response?.status,
data: error.response?.data,
};
}4. Modularize API Endpoints by Domain
Separate your API calls into domain-specific modules (e.g., users, products, billing) rather than writing inline HTTP calls inside UI components.
// api/services/userService.js
import { apiClient } from '../client';
export const userService = {
getUsers(params) {
return apiClient.get('/users', { params });
},
getUserById(id) {
return apiClient.get(`/users/${id}`);
},
createUser(payload) {
return apiClient.post('/users', payload);
},
updateUser(id, payload) {
return apiClient.put(`/users/${id}`, payload);
},
deleteUser(id) {
return apiClient.delete(`/users/${id}`);
},
};This separation ensures that changes to endpoint paths or request schemas are isolated from your view components.
5. Support Request Cancellation
Prevent race conditions and memory leaks on unmounted components by
supporting cancellation with the standard
AbortController.
// api/services/searchService.js
import { apiClient } from '../client';
export const searchService = {
search(query, signal) {
return apiClient.get('/search', {
params: { q: query },
signal, // Pass the AbortController signal
});
},
};Usage in a Component:
const controller = new AbortController();
searchService.search('term', controller.signal)
.then((results) => console.log(results))
.catch((err) => {
if (err.name !== 'CanceledError') {
console.error(err);
}
});
// Abort request when user types again or component unmounts
controller.abort();6. Type Your API Layer (TypeScript)
When using TypeScript, strictly type your request parameters and response data. This prevents runtime errors and enhances developer productivity.
// types/api.ts
export interface User {
id: string;
name: string;
email: string;
}
export interface CreateUserDTO {
name: string;
email: string;
}
// api/services/userService.ts
import { apiClient } from '../client';
import { User, CreateUserDTO } from '../../types/api';
export const userService = {
getUsers: (): Promise<User[]> => apiClient.get('/users'),
createUser: (data: CreateUserDTO): Promise<User> => apiClient.post('/users', data),
};Summary Checklist
- Instance: Use
axios.create()with a definedbaseURLandtimeout. - Interceptors: Automate auth header injection and handle token refreshing or errors globally.
- Separation: Organize calls into domain-specific services away from the UI logic.
- Cancellation: Use
AbortControllerto cancel stale requests. - Typing: Explicitly define input and output interfaces with TypeScript.