Skip to content
BoringStack
GitHub

OpenAPI client

4 min read

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

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.

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.

import { apiClient } from "@/lib/api/client";
const { data } = await apiClient.GET("/api/v1/users/me");
// ^? typed exactly as the API's response shape

A 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;
},
});

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.

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.

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.

Direct fetch() / axios / XMLHttpRequest outside src/lib/api/ fails the lint gate. See Lint as the contract.

src/lib/api/ on GitHub; client, middleware, error mapper, generated schema.