Consuming APIs with Axios in the Frontend
Practical notes on keeping the request/response layer clean in the UI using Axios interceptors, error handling, and shared configuration.
For a while I used the native fetch API to handle API requests in Vue projects. It works, it’s built into the browser, no extra dependencies. But rewriting the same headers on every request, manually parsing JSON, and handling errors separately everywhere bloated the code unnecessarily. After switching to Axios, all that repetition disappeared.
What is Axios?
Axios is a Promise-based HTTP client. It’s built on top of XMLHttpRequest in the browser and the http module on the Node.js side — the same interface works in both environments. Features like automatic JSON conversion, request cancellation, and interceptor support put it a step ahead of the native fetch API.
Installation is straightforward:
npm install axios
Basic usage
import axios from 'axios'
// GET request
axios.get('/api/users')
.then(response => {
console.log(response.data)
})
.catch(error => {
console.error(error)
})
// POST request
axios.post('/api/users', {
name: 'Muhammet',
email: '[email protected]'
})
.then(response => console.log(response.data))
response.data gives you direct access to the parsed JSON object — no need to call .json() manually. It’s a small thing, but you feel the relief once you’re using it consistently.
Shared configuration: an Axios instance
Creating a custom Axios instance is good practice so you don’t have to repeat the base URL and authorization header on every request:
// src/api/client.js
import axios from 'axios'
const apiClient = axios.create({
baseURL: process.env.VUE_APP_API_URL || '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
})
export default apiClient
From this point on you import apiClient everywhere — the /api prefix, headers, and timeout come along automatically.
Centralized processing with interceptors
An interceptor is a middleware that fires either before a request is sent or after a response is received. It’s ideal for attaching authorization tokens or catching errors in one central place.
Example request interceptor:
apiClient.interceptors.request.use(
config => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
Because every request passes through this interceptor, you never have to remember to attach the token at each call site.
Example response interceptor — terminating the session on a 401:
apiClient.interceptors.response.use(
response => response,
error => {
if (error.response && error.response.status === 401) {
// Token invalid; clear session and redirect to login
localStorage.removeItem('auth_token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
With these two interceptors, authorization and session management are consolidated in a single file.
Separating request modules
The Axios instance is ready; the next step is decoupling API calls from components. Writing a dedicated module for each resource keeps things clean:
// src/api/users.js
import apiClient from './client'
export const getUsers = () => apiClient.get('/users')
export const getUser = (id) => apiClient.get(`/users/${id}`)
export const createUser = (data) => apiClient.post('/users', data)
export const updateUser = (id, data) => apiClient.put(`/users/${id}`, data)
export const deleteUser = (id) => apiClient.delete(`/users/${id}`)
Usage in a Vue component:
import { getUsers } from '@/api/users'
export default {
data() {
return { users: [] }
},
async created() {
try {
const response = await getUsers()
this.users = response.data
} catch (error) {
console.error('Failed to load users', error)
}
}
}
The component has no knowledge of Axios — it just calls getUsers. If you ever need to swap out the HTTP library, only src/api/users.js changes.
Handling error cases
Axios automatically rejects the Promise on HTTP error statuses (4xx, 5xx). Inside the catch block you can check for the presence of error.response to distinguish a server error from a network error:
try {
await createUser(data)
} catch (error) {
if (error.response) {
// Server responded with a non-2xx status code
console.error('Server error:', error.response.status)
} else if (error.request) {
// Request was sent but no response was received (network issue)
console.error('Network error')
} else {
// Something went wrong while setting up the request
console.error('Request error:', error.message)
}
}
Comparison with fetch
A note for those coming from native fetch: fetch does not reject the Promise for 4xx or 5xx responses — you have to check response.ok yourself. Axios reverses this behavior, so error handling consolidates into catch. Which approach you prefer is a matter of taste, but if you need interceptors and automatic JSON conversion, Axios has the edge. If you want to avoid extra dependencies and your project is simple, fetch is perfectly fine.
Conclusion
Structuring Axios as a separate API layer rather than embedding it directly in components keeps frontend code maintainable. Interceptors gather centralized logic in one place; the modular structure decouples components from the HTTP library. Setting up this layer means a few extra files up front, but as the project grows that investment pays for itself.
One last gotcha: leaving an unhandled rejection in the interceptor chain can cause problems. If you forget to write return Promise.reject(error) in a response interceptor, the error gets swallowed and the catch block in your component never fires. This is particularly easy to miss when adding a 401 redirect — the user gets sent to the login page, but the component never processes the error state. When writing interceptors, make sure every code path either returns a value or a rejected Promise.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.