Centralized Fetch Utility with SWR
Build a single fetch utility that every client component references — consistent error handling, auth headers, base URL, and caching in one place.
Recipe
Quick-reference recipe card -- copy-paste ready.
// lib/fetcher.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "https://api.example.com";
export async function fetcher<T>(path: string): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getToken()}`,
},
});
if (!res.ok) {
const error = new Error("Fetch failed") as Error & { status: number };
error.status = res.status;
throw error;
}
return res.json() as Promise<T>;
}
function getToken(): string {
if (typeof window === "undefined") return "";
return localStorage.getItem("token") ?? "";
}// hooks/use-api.ts
"use client";
import useSWR from "swr";
import { fetcher } from "@/lib/fetcher";
export function useApi<T>(path: string | null) {
return useSWR<T>(path, fetcher);
}// Any client component
const { data, error, isLoading } = useApi<Post[]>("/posts");When to reach for this: You have multiple client components that fetch from the same API and you want one place to manage base URL, auth, headers, and error shape.
Working Example
Step 1: The Fetcher
The fetcher is a plain async function — no React, no hooks. SWR calls it with the key (the path) as the first argument.
// lib/fetcher.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "";
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown
) {
super(message);
this.name = "ApiError";
}
}
export async function fetcher<T>(path: string): Promise<T> {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
// Attach auth token if available
if (typeof window !== "undefined") {
const token = localStorage.getItem("token");
if (token) headers.Authorization = `Bearer ${token}`;
}
const res = await fetch(`${BASE_URL}${path}`, { headers });
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new ApiError(
body?.message ?? `Request failed: ${res.status}`,
res.status,
body
);
}
return res.json() as Promise<T>;
}Key decisions:
- Custom
ApiErrorclass carriesstatusand responsedataso consumers can branch on 401 vs 404 vs 500. typeof windowguard keeps the fetcher safe if accidentally called during SSR.- Base URL from env var means zero code changes between dev/staging/prod.
Step 2: The SWR Provider
Wrap your app in SWRConfig to set global defaults — every useSWR call inherits them.
// components/swr-provider.tsx
"use client";
import { SWRConfig } from "swr";
import { fetcher } from "@/lib/fetcher";
export function SWRProvider({ children }: { children: React.ReactNode }) {
return (
<SWRConfig
value={{
fetcher,
revalidateOnFocus: true,
revalidateOnReconnect: true,
errorRetryCount: 3,
dedupingInterval: 2000,
}}
>
{children}
</SWRConfig>
);
}// app/layout.tsx
import { SWRProvider } from "@/components/swr-provider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SWRProvider>{children}</SWRProvider>
</body>
</html>
);
}Why a provider? Without it, you pass fetcher to every useSWR call. The provider sets it once and every hook inherits it.
Step 3: The Hook
A thin typed wrapper around useSWR. Pass null as the key to skip fetching (conditional fetch).
// hooks/use-api.ts
"use client";
import useSWR, { type SWRConfiguration } from "swr";
import type { ApiError } from "@/lib/fetcher";
export function useApi<T>(
path: string | null,
options?: SWRConfiguration<T, ApiError>
) {
const { data, error, isLoading, isValidating, mutate } = useSWR<T, ApiError>(
path,
options
);
return { data, error, isLoading, isValidating, mutate };
}Step 4: Use It Everywhere
Every component uses the same hook. The fetcher handles auth, base URL, and error shaping behind the scenes.
// components/posts-list.tsx
"use client";
import { useApi } from "@/hooks/use-api";
type Post = { id: number; title: string; body: string };
export function PostsList() {
const { data: posts, error, isLoading } = useApi<Post[]>("/posts");
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<ul>
{posts?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}// components/user-profile.tsx
"use client";
import { useApi } from "@/hooks/use-api";
type User = { id: number; name: string; email: string };
export function UserProfile({ userId }: { userId: number }) {
const { data: user, isLoading } = useApi<User>(`/users/${userId}`);
if (isLoading) return <p>Loading profile...</p>;
return <h2>{user?.name}</h2>;
}// Conditional fetching -- skip until ready
function CommentSection({ postId }: { postId: number | null }) {
const { data: comments } = useApi<Comment[]>(
postId ? `/posts/${postId}/comments` : null
);
// ...
}Deep Dive
Why Centralize?
Without a centralized fetcher, every component duplicates:
- Base URL construction
- Auth header attachment
- Error parsing and status code handling
- Content-Type headers
One change (e.g., switching from localStorage tokens to cookies) means editing every fetch call. With the utility, you change one file.
SWR's Deduplication
SWR deduplicates requests by key. If three components on the same page call useApi<User>("/me"), SWR fires one network request and shares the result. This is why the key matters — it's both the cache key and the fetcher argument.
Component A: useApi("/me") ──┐
Component B: useApi("/me") ──┼── ONE fetch("/me") ── shared result
Component C: useApi("/me") ──┘
Mutation After Writes
After a POST/PUT/DELETE, tell SWR to refetch by calling mutate:
"use client";
import { useApi } from "@/hooks/use-api";
import { fetcher } from "@/lib/fetcher";
import { mutate } from "swr";
export function CreatePostForm() {
async function handleSubmit(formData: FormData) {
await fetch("/api/posts", {
method: "POST",
body: JSON.stringify({ title: formData.get("title") }),
headers: { "Content-Type": "application/json" },
});
// Revalidate the posts list across all components
mutate("/posts");
}
return (
<form action={handleSubmit}>
<input name="title" />
<button type="submit">Create</button>
</form>
);
}Optimistic Updates
SWR supports optimistic updates — update the cache immediately, then revalidate in the background:
async function toggleLike(postId: number) {
mutate(
`/posts/${postId}`,
(current: Post | undefined) =>
current ? { ...current, liked: !current.liked } : current,
{ revalidate: true }
);
await fetch(`/api/posts/${postId}/like`, { method: "POST" });
}Polling and Real-Time
// Poll every 5 seconds
const { data } = useApi<Notification[]>("/notifications", {
refreshInterval: 5000,
});Error Retry with Backoff
SWR retries failed requests with exponential backoff by default. Customize it globally in the provider or per-hook:
const { data } = useApi<T>("/path", {
errorRetryCount: 5,
onErrorRetry(error, key, config, revalidate, { retryCount }) {
if (error.status === 404) return; // don't retry 404s
setTimeout(() => revalidate({ retryCount }), 2 ** retryCount * 1000);
},
});Gotchas
-
SWR is client-only.
useSWRuses React state internally. You cannot call it in a Server Component. For server-side data, usefetchdirectly in the Server Component and pass the data as props to the SWR-powered client component for revalidation. -
Keys must be stable strings.
useSWRcompares keys by identity. Building keys with template literals is fine (/posts/${id}), but don't pass objects as keys — they create new references every render and break deduplication. -
nullkey skips fetching,undefineddoes not. To conditionally skip a fetch, passnullexplicitly. Passingundefinedwill serialize to"undefined"and fire a real request. -
The fetcher receives the key as its argument. If your key is
"/posts", the fetcher gets"/posts". If you need to pass extra arguments, use the array key form:useSWR(["/posts", userId], ([path, id]) => fetcher(path)). -
Don't destructure
mutatefrom the global import and the hook return. The globalmutatefromswrrequires a key argument. Themutatereturned fromuseSWRis already bound to that hook's key. Mixing them up causes silent bugs. -
Auth token changes require cache clearing. If the user logs out and logs back in as a different user, stale cached data from the previous session may display. Call
mutate(() => true, undefined, { revalidate: true })on auth change to clear all SWR cache. -
Provider must be a Client Component.
SWRConfiguses React context. The component that renders it needs"use client". The layout that imports it can remain a Server Component.
Alternatives
| Approach | When to use |
|---|---|
| TanStack Query | Need mutations with built-in optimistic updates, infinite queries, or framework-agnostic support |
Server Components + fetch | Data is static or only needed at render time — no client-side caching needed |
use() + Suspense | Unwrapping server-passed promises in client components |
Custom useFetch hook | Lightweight projects that don't want the SWR dependency |
Related
- Data Fetching in Server Components — server-side fetch patterns
- Caching — Next.js caching layers
- Revalidation — on-demand and time-based revalidation
- Streaming — progressive data loading with Suspense