Centralized Fetch Utility with Axios
Build a centralized API client with Axios - interceptors for auth, error handling, retries, and request/response transforms in one configured instance.
Busque em todas as páginas da documentação
Build a centralized API client with Axios - interceptors for auth, error handling, retries, and request/response transforms in one configured instance.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card -- copy-paste ready.
// lib/axios.ts
import axios from "axios";
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL ?? "https://api.example.com",
timeout: 10_000,
headers: { "Content-Type": "application/json" },
});
// Attach token to every request
api.interceptors.request.use((config) => {
const token = typeof window !== "undefined"
? localStorage.getItem("token")
: null;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Normalize errors
api.interceptors.response.use(
(res) => res,
(error) => {
if (error.response?.status === 401) {
window.location.href = "/login";
}
return Promise.reject(error);
}
);// Any component or server action
import { api } from "@/lib/axios";
const { data } = await api.get<Post[]>("/posts");When to reach for this: You need interceptors (auth injection, global error handling, request logging), automatic retries, request cancellation, or upload progress tracking - things that plain fetch requires manual plumbing for.
One configured instance replaces all raw fetch calls. Every request goes through the same interceptors.
// lib/axios.ts
import axios, {
type AxiosError,
type AxiosRequestConfig,
type InternalAxiosRequestConfig,
} from "axios";
// ---------- Instance ----------
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL ?? "",
timeout: 10_000,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
// ---------- Request Interceptor ----------
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Attach auth token
if (typeof window !== "undefined") {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error) => Promise.reject(error)
);
// ---------- Response Interceptor ----------
api.interceptors.response.use(
(response) => response,
(error: AxiosError<{ message?: string }>) => {
const status = error.response?.status;
// Global 401 redirect
if (status === 401 && typeof window !== "undefined") {
localStorage.removeItem("token");
window.location.href = "/login";
}
// Enrich error message from API response body
const serverMessage = error.response?.data?.message;
if (serverMessage) {
error.message = serverMessage;
}
return Promise.reject(error);
}
);Key decisions:
axios.create produces an isolated instance - it won't interfere with other libraries that use the global axios default.typeof window guards keep it safe if the module is imported on the server.Wrap the instance in typed functions so consumers don't deal with AxiosResponse directly.
// lib/api.ts
import { api } from "@/lib/axios";
// ---------- Types ----------
export type Post = { id: number; title: string; body: string };
export type User = { id: number; name: string; email: string };
export type Comment = { id: number; postId: number; body: string };
// ---------- Posts ----------
export const postsApi = {
getAll: () =>
api.get<Post[]>("/posts").then((r) => r.data),
getById: (id: number) =>
api.get<Post>(`/posts/${id}`).then((r) => r.data),
create: (data: Omit<Post, "id">) =>
api.post<Post>("/posts", data).then((r) => r.data),
update: (id: number, data: Partial<Post>) =>
api.put<Post>(`/posts/${id}`, data).then((r) => r.data),
delete: (id: number) =>
api.delete(`/posts/${id}`),
};
// ---------- Users ----------
export const usersApi = {
getMe: () =>
api.get<User>("/me").then((r) => r.data),
getById: (id: number) =>
api.get<User>(`/users/${id}`).then((r) => r.data),
};Why .then(r => r.data)? Axios wraps responses in { data, status, headers, ... }. Unwrapping in the API layer means every consumer gets clean typed data without reaching into .data.
// components/posts-list.tsx
"use client";
import { useEffect, useState } from "react";
import { postsApi, type Post } from "@/lib/api";
export function PostsList() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
postsApi
.getAll()
.then(setPosts)
.catch((err) => {
if (!controller.signal.aborted) {
setError(err.message);
}
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Axios and SWR work well together. Use Axios as SWR's fetcher for the best of both - interceptors from Axios, caching/revalidation from SWR.
// hooks/use-api.ts
"use client";
import useSWR, { type SWRConfiguration } from "swr";
import { api } from "@/lib/axios";
import type { AxiosError } from "axios";
const axiosFetcher = <T,>(url: string): Promise<T> =>
api.get<T>(url).then((r) => r.data);
export function useApi<T>(
path: string | null,
options?: SWRConfiguration<T, AxiosError>
) {
return useSWR<T, AxiosError>(path, axiosFetcher, options);
}// Now every component gets interceptors + SWR caching
const { data: posts } = useApi<Post[]>("/posts");
const { data: user } = useApi<User>("/me");// components/create-post-form.tsx
"use client";
import { useState } from "react";
import { postsApi } from "@/lib/api";
export function CreatePostForm({ onCreated }: { onCreated: () => void }) {
const [title, setTitle] = useState("");
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
try {
await postsApi.create({ title, body: "" });
setTitle("");
onCreated(); // trigger parent refetch
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to create post");
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button disabled={submitting}>
{submitting ? "Creating..." : "Create"}
</button>
</form>
);
}Interceptors run in a pipeline. Multiple request interceptors execute in reverse registration order. Multiple response interceptors execute in registration order.
Request: [interceptor 2] → [interceptor 1] → network
Response: [interceptor 1] → [interceptor 2] → your code
This matters when you have both a logging interceptor and an auth interceptor - registration order determines which sees the request first.
Axios supports AbortController (same API as fetch):
useEffect(() => {
const controller = new AbortController();
api.get<Post[]>("/posts", { signal: controller.signal })
.then((r) => setPosts(r.data))
.catch((err) => {
if (!axios.isCancel(err)) setError(err.message);
});
return () => controller.abort();
}, []);One of Axios's strongest differentiators over fetch:
async function uploadFile(file: File) {
const formData = new FormData();
formData.append("file", file);
const { data } = await api.post("/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress(event) {
const percent = Math.round((event.loaded * 100) / (event.total ?? 1));
console.log(`Upload: ${percent}%`);
},
});
return data;
}Implement automatic retry for transient failures:
api.interceptors.response.use(undefined, async (error: AxiosError) => {
const config = error.config as InternalAxiosRequestConfig & { _retryCount?: number };
if (!config) return Promise.reject(error);
config._retryCount = config._retryCount ?? 0;
const status = error.response?.status ?? 0;
// Retry on 5xx or network errors, up to 3 times
if (config._retryCount < 3 && (status >= 500 || status === 0)) {
config._retryCount += 1;
const delay = 2 ** config._retryCount * 500; // exponential backoff
await new Promise((r) => setTimeout(r, delay));
return api.request(config);
}
return Promise.reject(error);
});Axios works in Route Handlers and Server Actions, but without interceptors that access window. Create a separate server instance:
// lib/axios-server.ts
import axios from "axios";
export const serverApi = axios.create({
baseURL: process.env.API_URL, // no NEXT_PUBLIC_ prefix -- server only
timeout: 10_000,
headers: { "Content-Type": "application/json" },
});
// Server-side auth uses service tokens, not localStorage
serverApi.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${process.env.API_SERVICE_TOKEN}`;
return config;
});Axios adds ~13KB gzipped. If your only need is simple GET requests with useEffect, plain fetch or SWR's built-in fetcher is lighter. Axios pays off when you need interceptors, upload progress, or automatic retries.
Don't mutate the global axios default. Always use axios.create() for your project's instance. Libraries or third-party code that also import axios will inherit your interceptors if you modify the default.
Interceptors that access window break on the server. The same instance cannot safely run in both environments. Create separate client (lib/axios.ts) and server (lib/axios-server.ts) instances.
error.response can be undefined. Network errors and timeouts have no response. Always check error.response?.status before accessing response data, or use axios.isAxiosError(err) for type narrowing.
Axios serializes objects as JSON automatically. Unlike fetch, you don't need JSON.stringify(body). But for FormData uploads, you must override the Content-Type header or Axios will send JSON.
axios.isCancel() returns true for legacy CancelToken only. If using AbortController (recommended), check the error name with error.name === "CanceledError" or use axios.isCancel(error) which handles both.
Response interceptors see the full AxiosResponse. If you transform responses in an interceptor (e.g., return response.data), TypeScript types from api.get<T>() will be wrong because they expect an AxiosResponse<T>, not T. Keep unwrapping in the API functions, not interceptors.
Retry interceptors can cause infinite loops. Always track retry count on the config object and set a max. Without a cap, a persistent 500 error retries forever.
| Approach | When to use |
|---|---|
SWR with fetch | Client-side caching and revalidation without interceptor needs |
| TanStack Query + Axios | Full query/mutation lifecycle with Axios as the transport |
ky (fetch wrapper) | Want interceptor-like hooks but prefer the native fetch API surface |
Plain fetch + wrapper function | Minimal projects - no dependencies, just a typed wrapper |
ofetch (unjs) | Universal fetch that works in Node, browser, and workers with auto-retry |
Revisado por Chris St. John·Última atualização: 9 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥