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