Configuring a Shared Axios Instance with Interceptors

Using plain axios.get() in components works but does not scale. As the MERN Blog grows, you end up with the base URL, auth header, and error handling logic repeated in every component. The solution is a configured Axios instance โ€” a single object created once with the base URL, timeout, and interceptors already set up. Every API call in the application goes through this instance, guaranteeing consistent auth headers, consistent error handling, and a single place to update when the API configuration changes.

Creating the Axios Instance

// src/services/api.js
import axios from 'axios';

// Create a configured instance โ€” not the global axios object
const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || '/api',
  timeout: 15000, // 15 seconds โ€” requests that take longer are cancelled
  headers: {
    'Content-Type': 'application/json',
  },
});

export default api;
// client/.env (Vite environment variables โ€” must start with VITE_)
VITE_API_URL=/api          โ† development (Vite proxy handles /api โ†’ localhost:5000)

// client/.env.production
VITE_API_URL=https://api.mernblog.com    โ† production (full URL)

// In Vite, import.meta.env.VITE_API_URL reads the value.
// Never prefix with REACT_APP_ โ€” that is Create React App syntax.
// Variables not prefixed with VITE_ are not exposed to the browser.
Note: Axios instances are independent from the global axios object. Interceptors added to an instance only apply to requests made via that instance โ€” they do not affect calls made with the global axios directly. Always use the configured instance (api.get()) for your MERN app’s API calls, and the global axios only for third-party or one-off requests where you do not want the interceptors to run.
Tip: Set a reasonable timeout on your Axios instance. Without it, if the Express server goes down or a database query hangs indefinitely, the React component will wait forever with no feedback. A 15-second timeout means the user sees an error message after 15 seconds instead of a spinner that spins forever. In development you may want a longer timeout (30s) to avoid false timeouts when the server is slow to start.
Warning: Do not store the raw JWT token in Axios instance headers at creation time โ€” the token may not exist yet when the module loads, and it will change after login/logout. Instead, use a request interceptor that reads the current token from localStorage on every request. This ensures the latest token is always used, including after a token refresh.

Request Interceptor โ€” Automatic Auth Header

// src/services/api.js (continued)

// Request interceptor runs before every request sent by the api instance
api.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config; // must return config to continue with the request
  },
  (error) => {
    // Error creating the request (very rare)
    return Promise.reject(error);
  }
);

Response Interceptor โ€” Global Error Handling

// Response interceptor runs after every response received
api.interceptors.response.use(
  (response) => {
    // 2xx response โ€” pass through unchanged
    return response;
  },
  (error) => {
    const status = error.response?.status;

    // 401 Unauthorized โ€” token expired or invalid
    if (status === 401) {
      localStorage.removeItem('token');
      // Redirect to login โ€” preserving the current path as state
      window.location.href = `/login?from=${encodeURIComponent(window.location.pathname)}`;
    }

    // 503 Service Unavailable โ€” server is down
    if (status === 503) {
      console.error('API server is unavailable. Please try again later.');
    }

    // Re-reject so individual call sites can also handle the error
    return Promise.reject(error);
  }
);

The Complete api.js Module

// src/services/api.js โ€” complete module
import axios from 'axios';

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || '/api',
  timeout: 15000,
});

// โ”€โ”€ Request interceptor: attach token โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

// โ”€โ”€ Response interceptor: handle global errors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
api.interceptors.response.use(
  (res) => res,
  (err) => {
    if (err.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    return Promise.reject(err);
  }
);

export default api;

The Service Layer โ€” Wrapping API Endpoints

// src/services/authService.js
import api from './api';

const authService = {
  register: (data)  => api.post('/auth/register', data),
  login:    (data)  => api.post('/auth/login',    data),
  getMe:    ()      => api.get('/auth/me'),
  logout:   ()      => api.post('/auth/logout'),
};

export default authService;

// src/services/postService.js
import api from './api';

const postService = {
  getAll:   (params)    => api.get('/posts',      { params }),
  getById:  (id)        => api.get(`/posts/${id}`),
  create:   (data)      => api.post('/posts',      data),
  update:   (id, data)  => api.patch(`/posts/${id}`, data),
  remove:   (id)        => api.delete(`/posts/${id}`),
  publish:  (id)        => api.patch(`/posts/${id}/publish`),
};

export default postService;

// src/services/userService.js
import api from './api';

const userService = {
  getProfile:    (id)   => api.get(`/users/${id}`),
  updateProfile: (id, data) => api.patch(`/users/${id}`, data),
  getUserPosts:  (id, params) => api.get(`/users/${id}/posts`, { params }),
};

export default userService;

Using Services in Components

// Clean component โ€” no Axios code, no token management
import postService from '@/services/postService';

function PostListPage() {
  const [posts,   setPosts]   = useState([]);
  const [loading, setLoading] = useState(true);
  const [error,   setError]   = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    const fetchPosts = async () => {
      try {
        setLoading(true);
        const res = await postService.getAll({ page: 1, limit: 10, published: true });
        setPosts(res.data.data);
      } catch (err) {
        if (!axios.isCancel(err)) {
          setError(err.response?.data?.message || 'Failed to load posts');
        }
      } finally {
        setLoading(false);
      }
    };
    fetchPosts();
    return () => controller.abort();
  }, []);

  // ...
}

Common Mistakes

Mistake 1 โ€” Using the global axios instead of the configured instance

โŒ Wrong โ€” bypasses interceptors, no auth header, no base URL:

import axios from 'axios';
const res = await axios.get('/api/posts'); // no token, no timeout, no error handling

โœ… Correct โ€” always use the configured instance:

import api from '@/services/api';
const res = await api.get('/posts'); // baseURL + token + interceptors โœ“

Mistake 2 โ€” Re-rejecting errors in the interceptor with a different shape

โŒ Wrong โ€” wrapping the error breaks per-component error handling:

return Promise.reject(new Error('Something went wrong')); // loses err.response!

โœ… Correct โ€” always re-reject with the original error:

return Promise.reject(err); // โœ“ err.response still accessible in catch blocks

Mistake 3 โ€” Not awaiting service calls

โŒ Wrong โ€” service returns a Promise, not the data directly:

const result = postService.getById(id); // Promise โ€” not the data!
setPosts(result.data); // TypeError: Cannot read properties of a Promise

โœ… Correct โ€” await the service call:

const result = await postService.getById(id); // โœ“ resolved response object
setPosts(result.data.data);

Quick Reference

Task Code
Create instance const api = axios.create({ baseURL: '/api', timeout: 15000 })
Add request interceptor api.interceptors.request.use(config => { ... return config; })
Add response interceptor api.interceptors.response.use(res => res, err => Promise.reject(err))
Use in component import api from '@/services/api'; const res = await api.get('/posts')
Use via service const res = await postService.getAll({ page: 1 })
Vite env variable import.meta.env.VITE_API_URL

🧠 Test Yourself

Your Axios response interceptor handles the 401 case by calling window.location.href = '/login'. A colleague says this is a problem in a React SPA. Why and what is a better approach?