React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

axiosfetchutilityinterceptorsclient-componentsapi-client

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.create produces an isolated instance — it won't interfere with other libraries that use the global axios default.
  • Request interceptor adds auth. Response interceptor handles 401 globally.
  • typeof window guards 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

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

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

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

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

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

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

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

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

ApproachWhen to use
SWR with fetchClient-side caching and revalidation without interceptor needs
TanStack Query + AxiosFull 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 functionMinimal projects — no dependencies, just a typed wrapper
ofetch (unjs)Universal fetch that works in Node, browser, and workers with auto-retry