Back to Blog
AxiosFrontend ArchitectureAPI Design

Unifying API Contracts with Axios Interceptors

Use request and response interceptors for token injection, unified error handling, and consistent API calls across platforms

Author: ekent·Published on January 25, 2026

When a project has an admin dashboard, a mobile H5 site, and a mini-program, each client needs to handle: injecting tokens on requests, normalizing error responses, and redirecting to login on 401. Writing this separately for each client is a maintenance burden and a consistency risk. We wrapped all this logic into Axios interceptors that every client shares.

Basic Setup

Start with a configured Axios instance:

// lib/request.ts
import axios from 'axios';

const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 15000,
  headers: {
    'Content-Type': 'application/json',
  },
});

export default request;

Using the global axios.get() means all requests share one configuration—one change affects everything. axios.create() produces an isolated instance, and you can create different instances for different backend services.

Request Interceptor: Automatic Token Injection

request.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('access_token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

Now every API call gets the token automatically:

// No need for headers: { Authorization: `Bearer ${token}` }
const { data } = await request.get('/api/users/me');

One subtlety worth noting:

Abstract the token source. The example reads from localStorage, but mini-programs don't have localStorage—they use Taro.getStorageSync. Abstract the storage interface and the same interceptor works across platforms:

interface TokenStorage {
  getToken(): string | null;
  setToken(token: string): void;
  clear(): void;
}

// Web implementation
const webStorage: TokenStorage = {
  getToken: () => localStorage.getItem('access_token'),
  setToken: (t) => localStorage.setItem('access_token', t),
  clear: () => localStorage.removeItem('access_token'),
};

Response Interceptor: Unified Error Handling

Backend errors typically follow a consistent format. The response interceptor handles them centrally:

request.interceptors.response.use(
  (response) => {
    // Unwrap response.data so callers don't need to destructure
    return response.data;
  },
  (error) => {
    const status = error.response?.status;
    const message = error.response?.data?.message || 'Request failed';

    switch (status) {
      case 401:
        tokenStorage.clear();
        window.location.href = '/login';
        break;

      case 403:
        console.error('Permission denied:', message);
        break;

      case 404:
        console.error('Not found:', error.config?.url);
        break;

      case 429:
        console.error('Rate limited, please try again later');
        break;

      default:
        console.error(`Request error [${status}]:`, message);
    }

    return Promise.reject(error);
  }
);

The 401 Redirect Gotcha

Auto-redirecting on 401 seems simple, but here's the trap: if 5 concurrent requests all return 401, you trigger 5 redirects. Add a debounce:

let isRedirecting = false;

function handleUnauthorized() {
  if (isRedirecting) return;
  isRedirecting = true;

  tokenStorage.clear();
  window.location.href = '/login';

  setTimeout(() => { isRedirecting = false; }, 3000);
}

Type-Safe Return Values

With raw Axios, the return type is AxiosResponse<T>, requiring .data to access business data. The response interceptor already returns response.data, but TypeScript doesn't know that.

Fix it by redefining the types:

// Backend response format
interface ApiResponse<T> {
  code: number;
  data: T;
  message: string;
}

// Typed request wrappers
export async function get<T>(url: string, params?: object): Promise<T> {
  const res = await request.get<any, ApiResponse<T>>(url, { params });
  return res.data;
}

export async function post<T>(url: string, data?: object): Promise<T> {
  const res = await request.post<any, ApiResponse<T>>(url, data);
  return res.data;
}

Callers get automatic type inference:

interface User {
  id: number;
  name: string;
}

const user = await get<User>('/api/users/1');
// user is typed as User

Request Cancellation

When a page navigates away, pending requests should be cancelled. Otherwise, callbacks execute after the component unmounts, trying to update destroyed state.

import { useEffect } from 'react';

function useApiCancel() {
  useEffect(() => {
    const controller = new AbortController();

    request.get('/api/data', { signal: controller.signal });

    return () => controller.abort();
  }, []);
}

Takeaways

The value of Axios interceptors isn't in doing complex things—it's in centralizing repetitive logic: token injection, error handling, 401 redirects, response formatting. Once this contract is defined, business code only needs to care about which endpoint to call, what params to send, and what data to expect—not the infrastructure beneath it.