Typed data-fetching — @tedos/api-client (the one seam every Next app reads through)
canonical· frontend · updated 2026-06-27 · source
Every server read in a TED OS Next app goes through one typed transport, generated from the
backend contract — never a per-app hand-rolled fetcher. This is the realization of §BD (DF1–DF6)
in .claude/rules/react-style-guidelines.md, built
on the thin-app architecture (A1–A5) ratified by
ADR-008. Efforts #724 / #738.
TL;DR
@tedos/api-client(packages/api-client/src/index.ts) is the ONLY place HTTP + auth header + caching live. It wrapsopenapi-fetch, typed by the generated@tedos/api-types.@tedos/api-types(packages/api-types/src/index.ts) is generated byopenapi-typescriptfromapps/api/openapi.json. Regenerate withpnpm gen:api. The committed spec + types ARE the drift check.- Each app owns a ~10-line
lib/api-client.tsseam injecting onlybaseUrl+ a ClerkgetToken. Everything else is in the package. - Reads return a discriminated
ApiResult<T>(toResult), never a throw or a swallowed fallback — so the page can render the true error-vs-empty states. - Tenant data is
no-store(default in the package); cross-request dedupe isReact.cache(), not HTTP caching. - Offline fixtures are returned behind one flag (
apiConfigured) per resource.
Why this exists
Before #724 each app shipped its own engineFetch/engineGet: admin's lib/e1-api.ts and a
verbatim copy as comprender's lib/api.ts. Two transports drifted, neither was typed against the
contract, and several reads silently collapsed a failure into a zeroed fallback — so the page could
not tell "empty" from "the API is down". Both twins are now deleted (verified absent); the
transport lives once in the package (ADR-008 A2: reusable/presentational → a package), and the apps
keep only their auth wiring (A1/A4: auth stays app-owned).
The one transport — @tedos/api-client
packages/api-client/src/index.ts exports:
| Export | What it is |
|---|---|
createApiClient(config) | builds a typed Client<paths> (openapi-fetch). Attaches a bearer Middleware (onRequest sets Authorization: Bearer <token> from getToken) and defaults every request to cache: 'no-store'. |
isApiConfigured(baseUrl) | true when a non-empty base URL is set — the one offline flag. |
toResult(outcome) | collapses an openapi-fetch outcome into an ApiResult. Never throws. |
ApiResult<T> | { ok: true; data: T } | { ok: false; error: ApiError } |
ApiError | { status; message; body? }; status: 0 = unconfigured base URL or a network/transport error. |
ApiClient / ApiClientConfig / FetchOutcome<T> | the typed client alias + the seam config + the openapi-fetch result shape. |
Dependencies (packages/api-client/package.json): openapi-fetch + @tedos/api-types. The package
is framework-agnostic — no Clerk, no next/* import. It is RSC-safe by construction: you
construct + call it server-side only (the token getter reads the Clerk session).
toResult semantics (DF4):
error !== undefined(transport or non-2xx) →{ ok: false, error }data === undefined(2xx, empty body) →{ ok: false, error: 'API returned an empty response body' }- otherwise →
{ ok: true, data }— the page derives empty-vs-success from the data itself.
Generated types — @tedos/api-types
@tedos/api-types is not hand-written. packages/api-types/src/index.ts carries the
This file was auto-generated by openapi-typescript. Do not make direct changes header and exports
the paths map the client is typed against. The pipeline:
zod route schemas (@tedos/shared/api) ← the ONE hand-authored source of truth
│ pnpm -F @tedos/api gen:openapi (apps/api/src/scripts/gen-openapi.ts)
▼
apps/api/openapi.json ← committed spec (#721); offline Fastify+swagger dump, no DB/Clerk
│ pnpm -F @tedos/api-types gen (openapi-typescript)
▼
@tedos/api-types → @tedos/api-client → every app's lib/api-client.ts
Both steps run together via the root pnpm gen:api script (package.json). Run it after any
route/contract change; the committed spec + types ARE the drift check — regenerate → no diff
(DF2). The FE does not re-validate spec-typed responses; zod stays the server-side SoT.
gen-openapi.ts boots the Fastify app via build() without listening, asks @fastify/swagger
for the spec, and writes apps/api/openapi.json — deterministic, no running server, CI-safe (must
run with NODE_ENV != production, since swagger//docs are dev-only).
The thin per-app seam
Each app owns a ~10-line lib/api-client.ts that supplies only the app-specific bits — base URL +
the Clerk getToken:
- admin (
apps/admin/src/lib/api-client.ts):BASE = process.env.ENGINE_API_URL; exportsapiConfigured+api. projectId is resolved from the route param and passed into each data-fn (e.g.getSummary(projectId)). - comprender (
apps/clients/comprender/src/lib/api-client.ts): same shape,BASEdefaults tohttps://api.tuempresa.digital; also exportsapiBaseUrl(shared with the tenant-config seam). projectId is the Host-resolved tenant id (tenant-config.ts), per comprender's runtime multi-tenant MVP.
Both inject getToken: async () => (await auth()).getToken() (Clerk, A1/A4). projectId is always the
app's to resolve — never hardcoded in the package (DF3).
Data-fns — the Result contract in practice
A data-fn wraps one endpoint, returns ApiResult<T>, and branches on the offline flag. Naming
differs by app (admin: lib/*-fixtures.ts; comprender: lib/*-data.ts) but the shape is identical.
Example — comprender getSummary() (apps/clients/comprender/src/lib/summary-data.ts):
export async function getSummary(): Promise<ApiResult<GetSummaryResponse>> {
if (!apiConfigured) return { ok: true, data: FIXTURE_SUMMARY } // DF6: one fixture per resource
const { projectId } = await getTenantConfig()
const out = await api.GET('/p/{id}/summary', { params: { path: { id: projectId } } })
return toResult(out) // DF4: Result, never a throw/fallback
}
The page consumes it and reaches the 4 states (the rule file, H1) — e.g.
apps/clients/comprender/src/app/(shell)/summary/page.tsx does
const result = await getSummary(); if (!result.ok) { …state="error"… } and otherwise derives
empty-vs-success from the data. Admin mirrors this in summary-fixtures.ts (getSummary(projectId))
and students-fixtures.ts (getStudents / getStudent over GET /p/{id}/students and
GET /p/{id}/students/{sid}).
Caching — no-store + React.cache
- Tenant data is
no-store(DF5): the package setscache: 'no-store'on the client, so a read is never cached across requests/tenants. - Cross-request dedupe is
React.cache(), not HTTP caching. comprender'sgetTenantConfig(apps/clients/comprender/src/lib/tenant-config.ts) is wrapped incache(…)so the publicGET /tenant/configis fetched at most once per request and shared across the whole server tree.
Offline fixtures — one flag
When the API is unwired (apiConfigured false — local dev / preview without infra) a data-fn returns
a typed fixture as ok (DF6): one flag, one fixture per resource, never a second fetch path.
comprender additionally falls back to a fixed OFFLINE_TENANT config when the Host is local /
unresolvable, so the app always renders the populated example.
Boundaries
- The transport reaches the product API (
ENGINE_API_URL) — distinct from the dev-workflow Plan-Engine sync, which is a server-side concern inapps/apiand readsGH_PAT. - Auth never lives in the package — apps inject
getToken; the API fails closed (401) when there is no session, and the read surfaces an error (status from the response, or0for transport failures).
Related
- orientation.md — system map; this seam is the app→API data layer.
- ADR-008 — React style guidelines — A1–A5, the thin-apps-over-packages architecture this seam realizes.
- comprender MVP (E1) — the runtime multi-tenant consumer of this seam.
.claude/rules/react-style-guidelines.md— the enforceable rule digest, home of §BD (DF1–DF6) (the seam contract) and AP13 (the review blocker for hand-rolled fetchers / swallowed errors).