API Layer Architecture
The Scattered Fetch Anti-pattern
Open any fast-growing codebase and search for fetch(. You will find it in components, hooks, utility files, server actions, and sometimes even in CSS-in-JS theme resolvers. Each call handles errors differently (or not at all), each constructs URLs differently, and each parses responses differently.
This is not a codebase. This is chaos with a package.json.
Think of an embassy. Citizens do not negotiate directly with foreign governments -- they go through the embassy, which handles translation, protocol, security clearance, and documentation. Your API layer is the embassy between your frontend and the backend. Every request goes through one place. That place handles authentication tokens, error translation, retry logic, and response parsing. Your components never touch raw HTTP.
The API Layer Pattern
One module. One place for all HTTP communication. Every API call flows through it.
interface ApiClientConfig {
baseUrl: string;
getAuthToken?: () => Promise<string | null>;
onUnauthorized?: () => void;
}
function createApiClient(config: ApiClientConfig) {
async function request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const url = `${config.baseUrl}${path}`;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
if (config.getAuthToken) {
const token = await config.getAuthToken();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
}
const response = await fetch(url, { ...options, headers });
if (response.status === 401 && config.onUnauthorized) {
config.onUnauthorized();
throw new ApiError("Unauthorized", 401);
}
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new ApiError(
body?.message ?? `Request failed: ${response.status}`,
response.status,
body
);
}
if (response.status === 204) return undefined as T;
return response.json();
}
return {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) =>
request<T>(path, { method: "DELETE" }),
};
}
class ApiError extends Error {
constructor(
message: string,
public status: number,
public body?: unknown
) {
super(message);
this.name = "ApiError";
}
}
Now every part of your app uses the same client:
const api = createApiClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL!,
getAuthToken: () => getSession().then((s) => s?.accessToken ?? null),
onUnauthorized: () => redirect("/login"),
});
const courses = await api.get<Course[]>("/courses");
const course = await api.post<Course>("/courses", { title: "New Course" });
Request and Response Interceptors
Interceptors let you transform requests and responses without modifying each call.
type RequestInterceptor = (config: RequestInit & { url: string }) =>
Promise<RequestInit & { url: string }>;
type ResponseInterceptor = (response: Response) => Promise<Response>;
function createApiClientWithInterceptors(config: ApiClientConfig) {
const requestInterceptors: RequestInterceptor[] = [];
const responseInterceptors: ResponseInterceptor[] = [];
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
let requestConfig = { ...options, url: `${config.baseUrl}${path}` };
for (const interceptor of requestInterceptors) {
requestConfig = await interceptor(requestConfig);
}
const { url, ...fetchOptions } = requestConfig;
let response = await fetch(url, fetchOptions);
for (const interceptor of responseInterceptors) {
response = await interceptor(response);
}
if (!response.ok) {
throw new ApiError(`Request failed: ${response.status}`, response.status);
}
return response.json();
}
return {
request,
addRequestInterceptor(interceptor: RequestInterceptor) {
requestInterceptors.push(interceptor);
},
addResponseInterceptor(interceptor: ResponseInterceptor) {
responseInterceptors.push(interceptor);
},
};
}
Common interceptors:
function loggingInterceptor(config: RequestInit & { url: string }) {
console.log(`[API] ${config.method ?? "GET"} ${config.url}`);
return Promise.resolve(config);
}
function timingInterceptor(response: Response) {
const serverTiming = response.headers.get("Server-Timing");
if (serverTiming) {
console.log(`[API] Server-Timing: ${serverTiming}`);
}
return Promise.resolve(response);
}
Native fetch is the right default in 2024+. It is available everywhere (browsers, Node 18+, Deno, Bun, edge runtimes). Axios adds 13KB to your bundle for features you can build in 50 lines. Ky is a tiny (3KB) fetch wrapper that adds retries and hooks -- worth considering if you want a battle-tested solution without writing your own. For new projects, start with native fetch and extract a thin wrapper.
Error Handling Strategy
The API layer should translate HTTP errors into domain-specific errors that your UI understands.
type ApiErrorCode =
| "UNAUTHORIZED"
| "FORBIDDEN"
| "NOT_FOUND"
| "VALIDATION_ERROR"
| "RATE_LIMITED"
| "SERVER_ERROR"
| "NETWORK_ERROR";
class DomainApiError extends Error {
constructor(
message: string,
public code: ApiErrorCode,
public status: number,
public fieldErrors?: Record<string, string[]>
) {
super(message);
this.name = "DomainApiError";
}
}
function mapHttpError(status: number, body: unknown): DomainApiError {
const parsed = body as { message?: string; errors?: Record<string, string[]> } | null;
switch (true) {
case status === 401:
return new DomainApiError("Please log in to continue", "UNAUTHORIZED", status);
case status === 403:
return new DomainApiError("You do not have permission", "FORBIDDEN", status);
case status === 404:
return new DomainApiError("Resource not found", "NOT_FOUND", status);
case status === 422:
return new DomainApiError(
parsed?.message ?? "Invalid input",
"VALIDATION_ERROR",
status,
parsed?.errors
);
case status === 429:
return new DomainApiError("Too many requests, slow down", "RATE_LIMITED", status);
case status >= 500:
return new DomainApiError("Something went wrong on our end", "SERVER_ERROR", status);
default:
return new DomainApiError(parsed?.message ?? "Request failed", "SERVER_ERROR", status);
}
}
Now your components handle domain errors, not HTTP status codes:
try {
await api.post("/quiz/submit", { answers });
} catch (error) {
if (error instanceof DomainApiError) {
switch (error.code) {
case "VALIDATION_ERROR":
setFieldErrors(error.fieldErrors ?? {});
break;
case "RATE_LIMITED":
toast.error("You are submitting too fast. Please wait a moment.");
break;
default:
toast.error(error.message);
}
}
}
Retry Logic
Not all failures are permanent. Network blips, 503s, and rate limits are transient. A retry strategy handles them gracefully.
interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
retryableStatuses: Set<number>;
}
const defaultRetryConfig: RetryConfig = {
maxRetries: 3,
baseDelayMs: 1000,
retryableStatuses: new Set([408, 429, 500, 502, 503, 504]),
};
async function fetchWithRetry(
url: string,
options: RequestInit,
config: RetryConfig = defaultRetryConfig
): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
const response = await fetch(url, options);
if (!config.retryableStatuses.has(response.status)) {
return response;
}
if (attempt === config.maxRetries) {
return response;
}
lastError = new Error(`Retryable status: ${response.status}`);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt === config.maxRetries) {
throw lastError;
}
}
const delay = config.baseDelayMs * Math.pow(2, attempt);
const jitter = delay * (0.5 + Math.random() * 0.5);
await new Promise((resolve) => setTimeout(resolve, jitter));
}
throw lastError ?? new Error("Retry exhausted");
}
The exponential backoff with jitter (randomized delay) prevents thundering herd problems -- where thousands of clients retry simultaneously and overwhelm the server.
Never retry POST requests blindly. If the first request succeeded but the response was lost (network error), retrying creates a duplicate. POST retries need idempotency keys: a unique ID sent with the request so the server knows "I already processed this, here is the cached result." GET requests are always safe to retry because they are idempotent by definition.
Request Deduplication
If three components mount simultaneously and all call GET /courses, you should fire one request, not three.
const inflightRequests = new Map<string, Promise<unknown>>();
async function deduplicatedFetch<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const existing = inflightRequests.get(key);
if (existing) return existing as Promise<T>;
const promise = fetcher().finally(() => {
inflightRequests.delete(key);
});
inflightRequests.set(key, promise);
return promise;
}
Usage in your API client:
const courses = await deduplicatedFetch(
"GET:/courses",
() => api.get<Course[]>("/courses")
);
React 19 automatically deduplicates fetch calls with the same URL and options during server rendering. If two Server Components both call fetch('/api/courses'), only one HTTP request is made. For Client Components, you still need manual deduplication or a library like TanStack Query that does it automatically.
Type-Safe API Clients
The ultimate goal: your API client knows the exact shape of every request and response at compile time.
OpenAPI Codegen
If your backend provides an OpenAPI (Swagger) spec, generate a typed client from it:
npx openapi-typescript https://api.example.com/openapi.json -o src/lib/api/schema.ts
This generates TypeScript types for every endpoint. Libraries like openapi-fetch then create a type-safe client:
import createClient from "openapi-fetch";
import type { paths } from "./schema";
const client = createClient<paths>({ baseUrl: "https://api.example.com" });
const { data, error } = await client.GET("/courses/{courseId}", {
params: { path: { courseId: "abc-123" } },
});
Autocomplete shows you every valid path, every required parameter, and the exact response type. Typo in the path? Compile error. Wrong parameter type? Compile error.
tRPC
If you own both frontend and backend (full-stack TypeScript), tRPC eliminates the API layer entirely. Types flow from server to client with zero codegen.
const course = await trpc.course.getById.query({ id: "abc-123" });
No HTTP status codes to handle, no URL construction, no response parsing. The types come directly from the server function definition. We cover tRPC in depth in the next topic.
- 1All HTTP calls go through one API client -- zero scattered fetch calls in components
- 2The API layer translates HTTP errors into domain-specific error types
- 3Retry logic uses exponential backoff with jitter, and never retries non-idempotent requests without idempotency keys
- 4Request deduplication prevents redundant calls when multiple components mount simultaneously
- 5Type-safe clients via OpenAPI codegen (multi-language backends) or tRPC (full-stack TypeScript)
| What developers do | What they should do |
|---|---|
| Each component handles its own fetch, auth token, and error handling Scattered fetch calls lead to inconsistent error handling, missing auth tokens, and duplicated retry logic across the codebase. | Centralized API client handles auth, errors, and retries in one place |
| Retrying POST requests without idempotency keys Retrying a non-idempotent POST can create duplicates -- double charges, duplicate submissions, ghost records. | Only retry idempotent requests (GET, PUT, DELETE) or use idempotency keys for POST |
| Manually typing API responses with hand-written TypeScript interfaces Hand-written types drift from the actual API. Generated types are always in sync because they come from the source of truth. | Generating types from OpenAPI specs or using tRPC for automatic type inference |