Building an API client layer
Put the base URL, error types, auth refresh and test seams in one module, so no component ever calls fetch directly.
One place that knows the transport
A client layer exists to make three things true: every request shares the same base URL and default headers, every failure has one shape, and there is exactly one function to replace in a test. A thin wrapper is enough; a framework of interceptors is usually not.
const BASE = '/api';
const defaults = { headers: { Accept: 'application/json' } };
async function raw(path, init = {}) {
const url = path.startsWith('http') ? path : BASE + path;
const options = Object.assign({}, defaults, init, {
headers: Object.assign({}, defaults.headers, init.headers)
});
const res = await fetch(url, options);
const type = res.headers.get('content-type') || '';
const payload = type.includes('json') ? await res.json().catch(() => null) : await res.text();
if (!res.ok) throw ApiError.from(res, payload);
return payload;
}
export const api = {
get: (path, init) => raw(path, Object.assign({}, init, { method: 'GET' })),
post: (path, body, init) => raw(path, Object.assign({}, init, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}))
};- Resolve the base URL in one function. Relative paths are resolved against the document, so a page at a nested route and a page at the root would otherwise hit different endpoints.
- Parse the body before checking
ok, because error responses carry the useful detail. Read the content type rather than assuming JSON. res.json().catch(() => null)keeps a malformed body from replacing the real HTTP error with a syntax error.- Export named operations such as
api.tasks.list()above the raw helpers. Components that callapi.get('/tasks')still know the URL, which defeats the point.
Errors as types, not strings
class ApiError extends Error {
constructor({ status, code, message, details, payload }) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.details = details;
this.payload = payload;
}
static from(res, payload) {
const e = payload && payload.error ? payload.error : {};
return new ApiError({
status: res.status,
code: e.code || 'http_' + res.status,
message: e.message || 'Request failed with ' + res.status,
details: e.details || null,
payload: payload
});
}
get retryable() { return this.status === 429 || this.status >= 500; }
get needsAuth() { return this.status === 401; }
}
// callers branch on intent, not on numbers
try {
await api.post('/tasks', draft);
} catch (err) {
if (!(err instanceof ApiError)) throw err; // a programming error, not an HTTP one
if (err.needsAuth) return login();
if (err.retryable) return queueForLater(draft);
showFieldErrors(err.details);
}- Keep the raw payload on the error. Error reports without the body are almost always unresolvable after the fact.
- Distinguish transport failures from HTTP failures. A network error has no status, so give it status 0 and a distinct code rather than inventing a fake status.
- One error class with intent properties beats a hierarchy of twelve subclasses, and it survives an API change that adds a status code.
Seams and mock servers
// 1. inject the transport: the simplest seam, no library required
export function createApi({ fetchImpl = fetch, base = '/api' } = {}) {
async function raw(path, init) {
const res = await fetchImpl(base + path, init);
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
return { get: path => raw(path, { method: 'GET' }) };
}
// in a test
const fake = async () => ({ ok: true, json: async () => ({ data: [1, 2] }) });
const api = createApi({ fetchImpl: fake });
await api.get('/tasks'); // no network
// 2. intercept at the network level with a mock service worker, so the
// real URL parsing, headers and JSON parsing all run
// register handlers for 'GET /api/tasks' and return a fixture
// 3. a real server in an integration test: slower, but it is the only way
// to test CORS, cookies and redirects end to end⚠️
Mock responses drift from the real API and the drift is invisible: the tests keep passing while the application breaks in staging. Keep a fixture file captured from the real server, and add one integration test against a live or recorded backend for every shape you mock.
FAQ
How much should live in the client layer?
Base URL, default headers, credentials, error shape, auth refresh and the JSON parsing policy. Keep business rules and component state out of it, or the module becomes an application with no UI.
Should I use an HTTP library instead?
A small wrapper is usually enough for a browser application, because the platform already provides fetch, AbortController and streaming. A library earns its place when you need request retries, interceptors and adapters for several runtimes.
Related
Parsing, validating and modelling responses Authentication, tokens and CSRF
Last refreshed 2026-09-18.