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.
Recipe
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.
Working Example
Step 1: Create the Axios Instance
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.createproduces an isolated instance — it won't interfere with other libraries that use the globalaxiosdefault.- Request interceptor adds auth. Response interceptor handles 401 globally.
typeof windowguards keep it safe if the module is imported on the server.
Step 2: Typed API Functions
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.
Step 3: Use in Client Components
// 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>
);
}Step 4: Use with SWR (Axios as the fetcher)
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");Step 5: Mutations (POST, PUT, DELETE)
// 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>
);
}Deep Dive
Interceptor Execution Order
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.
Request Cancellation
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();
}, []);Upload Progress
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;
}Retry with Interceptors
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);
});Server-Side Usage (Route Handlers / Server Actions)
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;
});Gotchas
-
Axios adds ~13KB gzipped. If your only need is simple GET requests with
useEffect, plainfetchor SWR's built-in fetcher is lighter. Axios pays off when you need interceptors, upload progress, or automatic retries. -
Don't mutate the global
axiosdefault. Always useaxios.create()for your project's instance. Libraries or third-party code that also importaxioswill inherit your interceptors if you modify the default. -
Interceptors that access
windowbreak 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.responsecan beundefined. Network errors and timeouts have no response. Always checkerror.response?.statusbefore accessing response data, or useaxios.isAxiosError(err)for type narrowing. -
Axios serializes objects as JSON automatically. Unlike
fetch, you don't needJSON.stringify(body). But forFormDatauploads, you must override theContent-Typeheader or Axios will send JSON. -
axios.isCancel()returnstruefor legacyCancelTokenonly. If usingAbortController(recommended), check the error name witherror.name === "CanceledError"or useaxios.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 fromapi.get<T>()will be wrong because they expect anAxiosResponse<T>, notT. 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.
Alternatives
| 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 |
Related
- Centralized Fetch Utility with SWR — SWR-first approach to centralized fetching
- Data Fetching in Server Components — server-side fetch patterns
- Caching — Next.js caching layers
- Streaming — progressive data loading with Suspense