When building modern React applications, you'll likely communicate with backend APIs frequently. Instead of repeating code for authentication, error handling, and logging in every request, you can use Axios interceptors to handle these concerns globally and cleanly.
In this article, we’ll walk through interceptors step-by-step, building up to a powerful version that handles:
-
Authorization headers
-
Refresh token flow
-
Global error handling for 400 and 500 errors
Step 1: Create a Reusable Axios Instance :
First, install Axios:
npm install axios
// src/api/axios.js
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000,
});
export default api;
This allows you to attach interceptors once and reuse api everywhere.
Step 2: Add Request Interceptor for Authorization :
Most APIs require an access token for protected routes. Instead of adding it manually to each request, let’s add it automatically:
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
this code will :
-
Grabs the accessToken from local storage.
-
Appends it to the Authorization header of every request.
-
Errors in setting up the request are forwarded to .catch().
Step 3: Handle Token Expiration with Refresh Token Flow :
Access tokens expire. Here, we intercept 401 Unauthorized responses and automatically attempt to refresh the token using a refresh token.
Add logic to:
-
Detect 401 errors
-
Prevent multiple simultaneous refresh attempts
-
Retry the original request with a new token
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach((prom) => {
error ? prom.reject(error) : prom.resolve(token);
});
failedQueue = [];
};
Explanation:
-
isRefreshing prevents multiple refresh calls at once.
-
failedQueue holds all failed requests during refresh.
-
processQueue retries or rejects those requests after refresh completes.
Now extend the response interceptor:
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
return api(originalRequest);
})
.catch((err) => Promise.reject(err));
}
isRefreshing = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
const res = await axios.post('/auth/refresh-token', { refreshToken });
const newToken = res.data.accessToken;
localStorage.setItem('accessToken', newToken);
api.defaults.headers['Authorization'] = `Bearer ${newToken}`;
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
processQueue(null, newToken);
return api(originalRequest);
} catch (err) {
processQueue(err, null);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
Step 4: Handle 400 and 500 Errors Globally :
Next, let’s extend the interceptor to catch common error statuses like 400 and 500:
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Refresh logic (already shown above)
// Global Error Handling
if (error.response?.status === 400) {
console.error('Bad Request:', error.response.data?.message || 'Invalid input');
}
if (error.response?.status === 500) {
console.error('Server Error:', 'Something went wrong. Please try again later.');
}
return Promise.reject(error);
}
);
Optional enhancement: Replace console.error with a toast notification:
import { toast } from 'react-toastify';
toast.error('Invalid input, please check your form');
toast.error('Server error, please try again later');
Final Code: Axios Interceptor (Complete) :
Here is the final version of axios.js:
// src/api/axios.js
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000,
});
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach((prom) => {
error ? prom.reject(error) : prom.resolve(token);
});
failedQueue = [];
};
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
return api(originalRequest);
})
.catch((err) => Promise.reject(err));
}
isRefreshing = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
const res = await axios.post('/auth/refresh-token', { refreshToken });
const newToken = res.data.accessToken;
localStorage.setItem('accessToken', newToken);
api.defaults.headers['Authorization'] = `Bearer ${newToken}`;
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
processQueue(null, newToken);
return api(originalRequest);
} catch (err) {
processQueue(err, null);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
if (error.response?.status === 400) {
console.error('Bad Request:', error.response.data?.message || 'Invalid input');
}
if (error.response?.status === 500) {
console.error('Server Error:', 'Something went wrong. Please try again later.');
}
return Promise.reject(error);
}
);
export default api;
Using the Interceptor :
Anywhere in your app:
// src/services/userService.js
import api from '../api/axios';
export const getProfile = () => api.get('/user/profile');
You now have automatic token refresh, global error handling, and auth headers built in!
Conclusion :
Interceptors provide a scalable and clean way to handle common concerns in your HTTP communication layer:
-
Centralized token management
-
Global error feedback
-
Cleaner service logic
This approach keeps your React app secure, maintainable, and user-friendly.