OpenAPI client
OpenAPI client
The UI never hand-writes fetch(...). It calls a generated, typed client that
knows every path, every request body, and every response shape the API
exposes. When the API changes, you regenerate; if the UI now calls a path that
no longer exists, TypeScript tells you before the user does.
Generated
never hand-edited
Compile error
on drift
Silent refresh
on 401
How it stays in sync
Section titled “How it stays in sync”flowchart LR api["apps/api<br/>publishes /swagger/json"] gen["bun run generate:api"] schema["src/lib/api/schema.d.ts<br/>generated types"] client["apiClient.GET / POST / ..."] api --> gen gen --> schema schema --> client client -.->|HTTP| api
bun run generate:api runs openapi-typescript against the live API (or a saved spec) and emits one big .d.ts of paths, params, and response components. openapi-fetch wraps native fetch and uses those types so every call is path- and shape-checked.
Design choices
Section titled “Design choices”Generated, never hand-edited
Drift between server and client is a compile error, not a runtime 500.
One client module (apiClient); direct fetch/axios is a lint error
Single place for base URL, cookie credentials, error mapping, refresh logic.
Throws ApiError on non-2xx
TanStack Query error is typed and structured; no string parsing.
Silent refresh on 401 with a single in-flight guard
Parallel queries do not trigger N refresh storms.
Refresh exempts /auth/refresh + /auth/login themselves
No infinite loops when the refresh itself fails.
Using it
Section titled “Using it”import { apiClient } from "@/lib/api/client";
const { data } = await apiClient.GET("/api/v1/users/me");// ^? typed exactly as the API's response shapeA path that doesn’t exist in the schema is a compile error. A body that doesn’t match is a compile error. data is fully typed.
Inside TanStack Query:
useQuery({ queryKey: ["users", "me"], queryFn: async () => { const { data, error } = await apiClient.GET("/api/v1/users/me"); if (error) throw new ApiError(error); return data; },});The middleware layer
Section titled “The middleware layer”openapi-fetch is configured with credentials: "include", so the browser sends auth_token and refresh_token cookies automatically. The UI never reads a JWT, stores a bearer token, or adds an Authorization header.
openapi-fetch accepts middleware. The template ships one: on a 401, kick off a single /auth/refresh (with a module-level promise guarding against parallel triggers), then retry the original request. Refresh exempts itself + /auth/login so a failed refresh never recurses. If the refresh fails, the original 401 propagates and ProtectedRoute redirects to /login.
Regenerating
Section titled “Regenerating”API spec changed (most common)
Run bun run generate:api against the running dev API.
Working from a committed spec
Point generate:api at a saved .json.
CI consistency check
Run bun run generate:api && git diff --exit-code src/lib/api/schema.d.ts.
The CI check fails if a developer changed the API but forgot to regenerate; drift gets caught at PR time, not at runtime.
Adding a call
Section titled “Adding a call”There’s no “adding”. If the API exposes a new endpoint, bun run generate:api makes it available; you call it the same way you call any other.
Lint coverage
Section titled “Lint coverage”Direct fetch() / axios / XMLHttpRequest outside src/lib/api/ fails the lint gate. See Lint as the contract.
Source
Section titled “Source”src/lib/api/ on GitHub; client, middleware, error mapper, generated schema.
Related
Section titled “Related”- API template overview; where the OpenAPI spec comes from.
- Architecture rules; the component layer that consumes the client via queries.