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.
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.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.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 |