Trong các ứng dụng Next.js thực tế, việc giao tiếp với backend thường không đơn thuần là gọi một lệnh fetch hay axios.get().
Một ứng dụng doanh nghiệp (Enterprise) thường phải đối mặt với các bài toán phức tạp:
- Nhiều Backend khác nhau: Gọi song song cả Next.js Internal Route Handler (
/api/...) và Microservices / SaaS Backend ngoài. - Xác thực JWT Token: Tự động đính kèm
Authorization: Bearer <token>vào mọi request mà không cần truyền thủ công ở từng component. - Tự động gia hạn phiên đăng nhập (Silent Refresh Token): Khi Access Token hết hạn (lỗi
401 Unauthorized), hệ thống phải tự động gọi API lấy token mới, nạp lại hàng đợi các request bị tạm hoãn và tiếp tục thực thi mà người dùng không hề hay biết. - Xử lý lỗi tập trung: Chuẩn hóa thông báo lỗi từ server để UI hiển thị Toast đồng nhất.
Bài viết này sẽ hướng dẫn bạn thiết lập một kiến trúc Axios Client Factory hoàn chỉnh, bảo mật và chuẩn TypeScript từ A-Z.
1. Vì sao nên dùng Axios Factory thay vì một instance duy nhất?
Nếu bạn chỉ tạo một axios.create({ baseURL: ... }) duy nhất, khi dự án cần gọi nhiều nguồn API (ví dụ: vừa gọi API nội bộ Next.js, vừa gọi Backend SaaS bên ngoài, vừa gọi bên thứ ba như Google Maps hay Stripe), bạn sẽ phải ghi đè URL rất lộn xộn.
Mô hình Factory (createApiClient) cho phép tạo ra nhiều instance chuyên biệt nhưng vẫn chia sẻ chung bộ interceptors xử lý token và bắt lỗi.
# Cài đặt Axios
pnpm add axios2. Cấu trúc file cấu hình apiClient.ts hoàn chỉnh
Tạo file src/services/apiClient.ts chứa toàn bộ logic tạo client, quản lý token và interceptors:
import axios, {
type AxiosError,
type AxiosInstance,
type AxiosRequestConfig,
type InternalAxiosRequestConfig,
} from "axios";
// 1. Helper lấy và lưu Token (tương thích Cookie / LocalStorage)
export const tokenStorage = {
getAccessToken: (): string | null => {
if (typeof window === "undefined") return null;
return localStorage.getItem("access_token");
},
getRefreshToken: (): string | null => {
if (typeof window === "undefined") return null;
return localStorage.getItem("refresh_token");
},
setTokens: (accessToken: string, refreshToken?: string) => {
if (typeof window === "undefined") return;
localStorage.setItem("access_token", accessToken);
if (refreshToken) localStorage.setItem("refresh_token", refreshToken);
},
clearTokens: () => {
if (typeof window === "undefined") return;
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
},
};
// 2. Biến hàng đợi chống gọi trùng Refresh Token nhiều lần
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
}> = [];
const processQueue = (error: AxiosError | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
// 3. Axios Client Factory
export function createApiClient(baseURL?: string): AxiosInstance {
const instance = axios.create({
baseURL: baseURL || process.env.NEXT_PUBLIC_API_URL || "",
timeout: 30000, // Timeout 30s
headers: {
"Content-Type": "application/json",
},
});
// ==========================================
// REQUEST INTERCEPTOR: Tự động gắn Token
// ==========================================
instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = tokenStorage.getAccessToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// ==========================================
// RESPONSE INTERCEPTOR: Xử lý 401 & Refresh Token
// ==========================================
instance.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// Nếu lỗi 401 và request này chưa từng retry
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Nếu đang có 1 tiến trình refresh token chạy, xếp request này vào hàng đợi
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
return instance(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = tokenStorage.getRefreshToken();
if (!refreshToken) {
tokenStorage.clearTokens();
if (typeof window !== "undefined") {
window.location.href = "/admin/login";
}
return Promise.reject(error);
}
try {
// Gọi API refresh token
const { data } = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, {
refreshToken,
});
const newAccessToken = data.accessToken;
const newRefreshToken = data.refreshToken;
tokenStorage.setTokens(newAccessToken, newRefreshToken);
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
}
processQueue(null, newAccessToken);
return instance(originalRequest);
} catch (refreshError) {
processQueue(refreshError as AxiosError, null);
tokenStorage.clearTokens();
if (typeof window !== "undefined") {
window.location.href = "/admin/login";
}
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
return instance;
}
// 4. Khởi tạo các instances chuyên biệt
export const internalApi = createApiClient(""); // Gọi API nội bộ Next.js (/api/...)
export const saasApi = createApiClient(process.env.NEXT_PUBLIC_BACKEND_URL); // Gọi Backend ngoài3. Định nghĩa API Service chuẩn Type-Safe
Tận dụng instance vừa tạo để viết các hàm API rõ ràng, có đầy đủ kiểu dữ liệu TypeScript cho cả tham số gửi đi và kết quả trả về:
import { saasApi } from "@/src/services/apiClient";
export interface Studio {
id: string;
name: string;
plan: "free" | "pro" | "enterprise";
createdAt: string;
}
export interface UpdatePlanDto {
plan: "free" | "pro" | "enterprise";
expireAt?: string;
}
export const studioApi = {
// Lấy danh sách studio
getList: async (page = 1, search = ""): Promise<{ data: Studio[]; total: number }> => {
const res = await saasApi.get("/admin/studios", {
params: { page, search },
});
return res.data;
},
// Cập nhật plan
updatePlan: async (id: string, payload: UpdatePlanDto): Promise<Studio> => {
const res = await saasApi.put(`/admin/studios/${id}/plan`, payload);
return res.data;
},
};Facing similar challenges?
I can help you optimize your website — reach out for a free consultation.
4. Kết hợp mượt mà với TanStack Query
Khi kết hợp Axios Service với TanStack Query, component UI của bạn chỉ cần 2 dòng code để hiển thị dữ liệu hoặc trigger cập nhật:
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { studioApi, type UpdatePlanDto } from "./studioApi";
export function useStudioListQuery(page: number, search: string) {
return useQuery({
queryKey: ["adminStudios", page, search],
queryFn: () => studioApi.getList(page, search),
staleTime: 30 * 1000,
});
}
export function useUpdateStudioPlanMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpdatePlanDto }) =>
studioApi.updatePlan(id, payload),
onSuccess: () => {
// Làm mới danh sách studio sau khi đổi plan thành công
queryClient.invalidateQueries({ queryKey: ["adminStudios"] });
},
});
}5. Xử lý thông báo lỗi chuẩn hóa (Centralized Error Handling)
Để không phải viết try/catch và đọc error.response?.data?.message ở từng component, bạn có thể tạo một helper chuẩn hóa lỗi:
import { AxiosError } from "axios";
export function getErrorMessage(error: unknown): string {
if (error instanceof AxiosError) {
// Ưu tiên đọc message trả về từ backend
if (error.response?.data?.message) {
return error.response.data.message;
}
if (error.response?.status === 403) {
return "Bạn không có quyền thực hiện thao tác này.";
}
if (error.response?.status === 404) {
return "Dữ liệu yêu cầu không tồn tại.";
}
if (error.code === "ECONNABORTED") {
return "Kết nối mạng quá hạn (Timeout). Vui lòng thử lại.";
}
}
return "Đã xảy ra lỗi không xác định. Vui lòng thử lại sau.";
}Kết luận
Việc thiết lập một Axios Client Factory chuẩn chỉnh ngay từ đầu với đầy đủ các tính năng: phân tách nhiều Base URL, tự động gắn token, tự động Refresh Token qua hàng đợi và chuẩn hóa TypeScript sẽ giúp toàn bộ codebase của bạn trở nên vững chắc, an toàn và dễ dàng mở rộng trong tương lai.
Want to discuss further?
If you have specific questions about web performance, React, or want to collaborate on a project — reach out directly.