The Client Layer
IntermediateOne module that owns the base URL, auth, error normalisation and types. Components should never see a fetch call or a status code.
Overview
Scattering fetch calls through components means the base URL is repeated, auth is attached inconsistently, and every caller re-implements error handling slightly differently. A thin client layer fixes all three, and gives you one place to add a request id, a timeout, or a retry later. The second half of this is types: hand-written interfaces mirroring your backend models drift silently, and the fix is to generate them from the OpenAPI schema your backend already publishes.
One fetch Wrapper
Base URL, credentials, JSON, and errors normalised into one shape.
// lib/api/client.ts
const BASE = process.env.NEXT_PUBLIC_API_URL
export class ApiError extends Error {
constructor(readonly status: number, readonly code: string,
readonly fields?: Record<string, string>,
readonly requestId?: string) {
super(code)
}
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
credentials: 'include', // cookies, every time
headers: {
'Content-Type': 'application/json',
'X-Request-Id': crypto.randomUUID(), // correlate with server logs
...init.headers,
},
})
if (res.status === 204) return undefined as T
const body = await res.json().catch(() => null)
if (!res.ok) {
const e = body?.error ?? {}
throw new ApiError(res.status, e.code ?? 'unknown', e.fields,
res.headers.get('X-Request-Id') ?? undefined)
}
return body as T
}
// Callers never see a status code:
export const listProblems = (p: ListParams) =>
api<Paged<Problem>>(`/problems?${new URLSearchParams(p)}`)Generated Types
The backend already publishes a schema. Generate from it rather than transcribing it.
# FastAPI serves OpenAPI for free at /openapi.json
npx openapi-typescript http://localhost:8000/openapi.json \
-o src/generated/api.ts
// Then the types come from the server, not from memory
import type { paths } from '@/generated/api'
type Problem =
paths['/problems/{slug}']['get']['responses']['200']['content']['application/json']
// The failure this prevents: the backend renames a field, the
// frontend keeps compiling against a stale hand-written interface,
// and the bug reaches production as an undefined.
// Make drift a build failure, not a habit:
# npm run generate:api && git diff --exit-code src/generated/
// A red CI run is how you learn the contract moved.
// Spring Boot: springdoc-openapi publishes the same schema.
// Generators exist for clients too (openapi-generator), though the
// types-only approach keeps the runtime code yours.
// Committing the generated file is the usual choice — but it goes
// stale silently if nobody regenerates, which is exactly why the
// CI check matters.Attaching Auth and Context
Interceptor-style behaviour, without repeating it in every call.
// With cookies there is nothing to attach — credentials: 'include'
// in the wrapper is the whole story, and the token never touches JS.
// With a bearer token, the wrapper owns it (see the refresh concept
// for the concurrent-401 handling):
function withAuth(init: RequestInit): HeadersInit {
const token = getAccessToken() // in memory, not localStorage
return token ? { ...init.headers, Authorization: `Bearer ${token}` }
: init.headers ?? {}
}
// Server-side calls in Next.js need cookies forwarded explicitly —
// there is no browser to attach them:
import { cookies } from 'next/headers'
const res = await fetch(`${API}/me`, {
headers: { cookie: cookies().toString() },
cache: 'no-store',
})
// Forgetting this is why a server component sees an anonymous user
// while the same call works in the browser.
// Other things that belong in the wrapper, not in components:
// - a timeout (AbortSignal.timeout(10_000))
// - a request id, and reading it back off error responses
// - locale or tenant headers
// - a 401 -> refresh -> retry once policy
// - dev-only logging of slow requests
// And keep the layers separate:
// client.ts transport: URL, auth, errors, retries
// api/*.ts one function per endpoint, typed
// hooks/*.ts useQuery/useMutation wrapping those functions
// Components import the hooks and nothing below them.Key Points to Remember
- 1A single wrapper owns the base URL, credentials, JSON parsing and error normalisation so components never see a status code
- 2Normalise failures into one ApiError carrying status, a stable code, field errors and a request id
- 3Generate types from the backend OpenAPI schema and fail CI on drift rather than hand-writing interfaces
- 4Server-side rendering has no browser to attach cookies — forward them explicitly or the call is anonymous
- 5Keep transport, per-endpoint functions and data hooks in separate layers, with components importing only the hooks
Interview Questions
Sign in to ask AriaWhy centralise fetch calls in a client module instead of calling fetch in components?
How do you keep frontend types from drifting away from the backend contract?
Why might an authenticated request work in the browser but return 401 during server rendering?
Ask Aria about The Client Layer
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.