Building an API Client for the Express Backend
Most apps don’t need a full client library to talk to their backend.
But raw fetch gets repetitive quickly - headers, JSON parsing, error handling, credentials. The same concerns show up in every request.
This is a small wrapper I use to keep those details in one place.
The idea
- Call the API with a consistent interface
- Always handle JSON and errors the same way
- Forward cookies automatically
- Keep it minimal
In Next.js rewrite proxies /api/v1/* → ${BACKEND_ORIGIN}/api/*, so requests stay same-origin and cookies just work.
The Client
/**
* Lightweight fetch wrapper for the Express API.
*
* In Next.js rewrite proxies `/api/v1/*` → `${BACKEND_ORIGIN}/api/*`,
* so we call the same origin and cookies are forwarded automatically.
*/
const BASE = '/api/v1';
export class ApiError extends Error {
constructor(
public status: number,
public body: Record<string, unknown>,
) {
super((body.error as string) ?? `Request failed with status ${status}`);
this.name = 'ApiError';
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new ApiError(res.status, body);
}
// 204 No Content
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
}),
put: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
}),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'PATCH',
body: body ? JSON.stringify(body) : undefined,
}),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};Usage
import { api } from '@/lib/api';
// example usage
type User = {
id: string;
name: string;
};
const user = await api.get<User>('/users/me');
await api.post('/posts', {
title: 'Hello World',
});No repeated config. No scattered error handling. Just a thin layer over fetch.
Design choices
A few deliberate decisions:
-
Errors are thrown, not returned: Keeps call sites clean and works naturally with
try/catch. -
Generics over validation: This assumes your backend is trustworthy. If not, add runtime validation separately.
-
JSON by default: Most APIs speak JSON. Optimize for that case, handle edge cases explicitly.
-
Cookies included: Useful for session-based auth without extra wiring.
Limitations
This is intentionally small. It does not:
- retry failed requests
- refresh tokens
- validate response shapes at runtime
- support non-JSON APIs well
If you need those, this becomes a different abstraction.
Closing thought
You don’t need a heavy client to talk to your backend.
A small, predictable wrapper like this is often enough and easier to reason about when things go wrong.