Back to writing

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

lib/api.ts
/**
 * 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

pages/profile.tsx
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.