ADR-0036 API layer
Company standard HTTP API layer — TanStack Start server routes under /api/*, with handlers in src/server/api/ and token-only authentication. Hono only when a product publishes an API contract.
- Decision
- Start server routes at /api/*Build the app
- Status
- Accepted
- Date
- Scope
- Company-wide default for all new apps
Connections
Builds on
Context
Section titled “Context”ADR-0004 put Hono at /api/*, mounted by one splat route inside the TanStack Start server, and chose it over Start’s own server routes for web-standard APIs, an HTTP middleware ecosystem, Zod validation middleware, a path to OpenAPI, and a clean extraction to a standalone service.
Since then:
- Start’s server routes caught up on middleware. A file route takes
server.middlewarefor every method and per-method middleware throughcreateHandlers— the samecreateMiddlewarethat server functions andsrc/start.tsuse. Handlers take a web-standardRequestand return aResponse, as Hono’s do - What
/api/*serves in practice is Better Auth’s handler, webhooks, and a few endpoints for a mobile app or a script. Few products publish an API contract to partners - Hono has a running cost inside Start: a second router and a second middleware system, a splat route to bridge them, duplicate server spans from
@hono/otel, a rule against Hono’srequestId()minting a second ID (ADR-0015), and Hono’s own CSRF middleware for AI routes (ADR-0025) - ADR-0027 says YAGNI: OpenAPI generation and an extraction path are worth a framework only for a product that needs them
What is open: what serves /api/* by default, and how its endpoints validate, authenticate, and get tested.
Options considered
Section titled “Options considered”1. Keep Hono (ADR-0004)
Section titled “1. Keep Hono (ADR-0004)”Pros: @hono/zod-openapi for a spec and generated clients; a typed client (hc); built-in CORS, secure-headers, and validator middleware; the API extracts to its own service unchanged.
Cons: two routers and two middleware systems in every app, the glue between them, duplicate telemetry, and conventions to explain — paid by every product, used by few.
2. Start server routes
Section titled “2. Start server routes”Pros
- One router: API endpoints are file routes, in the route tree, with their patterns in
http.route - One middleware system for pages, server functions, and API routes
- No dependency; validation is
safeParsewith the schemas we already share (ADR-0005)
Cons
- No OpenAPI generation and no generated clients
- No CORS middleware; write one when a product needs it
- Coupled to Start: moving the API to its own service means rewriting the route wiring
3. tRPC
Section titled “3. tRPC”Pros: typed RPC with good TypeScript ergonomics. Cons: does what server functions already do for our UI, and is not a REST contract for outside clients — a third server pattern for no gain.
Decision
Section titled “Decision”TanStack Start server routes under /api/* are the company standard for HTTP endpoints; server functions stay the way our own UI talks to the server. A product adds Hono only through its own ADR, when it publishes an API contract (OpenAPI, generated clients) for partners or mobile apps.
Two server patterns, one rule (unchanged from ADR-0004):
- Server functions → data loading and mutations for our own React app only
- Server routes under
/api/*→ anything consumed as HTTP: Better Auth, webhooks, mobile apps, partners, scripts
Concretely:
-
Route files only wire; handlers live in
server/api/(ADR-0026). A route file maps methods to handlers, and the handlers parse, authorize, and call the service:apps/web/src/routes/api/v1/invoices.ts import { createFileRoute } from "@tanstack/react-router";import { createInvoice, listInvoices } from "@/server/api/v1/invoices";export const Route = createFileRoute("/api/v1/invoices")({server: { handlers: { GET: listInvoices, POST: createInvoice } },});// apps/web/src/server/api/v1/invoices.ts (excerpt)export const createInvoice: ApiHandler = async ({ request, context }) => {const input = await readJson(request, invoiceInput);if (input instanceof Response) return input; // 415 or 400, the same shape on every route// authorize (ADR-0033), then call the servicereturn Response.json(await invoices.create(context.caller, input), { status: 201 });}; -
Versioned paths:
/api/v1/<domain>, plural like the table and the page. A breaking change is a new/api/v2/route, and the old one stays until its clients have moved -
Validation:
readJson(request, schema)andreadQuery(url, schema)inserver/api/http.tscheckContent-Type: application/json(415otherwise), parse, and validate with the shared Zod schema. Malformed JSON and a failed schema both answer400withz.flattenError(error), so every route fails the same way -
API callers authenticate with a token, never the session cookie.
apiCaller, a request middleware insrc/start.ts, runs for every versioned path (/api/v…). It passes only theAuthorizationheader to Better Auth — the bearer or API-key plugin, chosen per product (ADR-0009) — puts the caller incontext, and answers401when there is none. A browser never attaches that header on its own, so a cross-site page cannot make an authenticated call, and these routes need no CSRF check; our UI’s writes go through server functions, which have one (ADR-0013). Each handler then checks a permission (ADR-0033) -
Better Auth is mounted by
routes/api/auth/$.ts, whoseGETandPOSThand the request toauth.handler(request)— Better Auth’s documented TanStack Start setup. It checks origins itself (trustedOrigins) -
Webhooks live under
/api/webhooks/<provider>, outside the versioned paths, and verify the provider’s signature on the raw body before parsing it -
No CORS by default — our UI is same-origin. A product whose API serves browsers on other origins adds a CORS middleware with an explicit origin list, never
*with credentials -
Rate limits, request IDs, and tracing come from
src/start.ts, as for every request (ADR-0010, ADR-0015); spans are named after the route’s pattern, such asPOST /api/v1/invoices -
Tests: integration tests call the handlers in
server/api/v1/with aRequestand a caller, against the real database (ADR-0014); E2E covers the middleware chain in front of them -
A product that publishes a contract writes an ADR that adopts Hono with
@hono/zod-openapiunder that API’s prefix, or generates the spec from the shared Zod schemas
The deciding factors: one router and one middleware system in every app, nothing installed for what Start already does, and OpenAPI paid for only by the products that publish an API. We accept writing two small helpers ourselves and losing an extraction path that no product has used.
Consequences
Section titled “Consequences”Easier:
- Pages, server functions, and API endpoints are all file routes behind one middleware chain; nothing bridges two frameworks
- API routes appear in the route tree, and their patterns name spans and metrics with no extra package
- One package fewer to keep current, and no Hono-specific rules for request IDs, CSRF, or telemetry
Harder:
- No OpenAPI spec by default: a product that later publishes an API adopts Hono or a generator then, in its own ADR
- Moving the API to a standalone service means rewriting the route wiring; the handlers are plain functions in
server/api/, so the rewrite stays thin apiCallermust match every versioned path; a guard test calls a/api/v1/route with a session cookie and no token and expects401- CORS, when a product needs it, is ours to write and review
Given up: Hono’s middleware catalogue and its OpenAPI path as defaults, and extracting the API without a rewrite. Accepted — a product that needs them says so in its own ADR, and every other product runs one framework instead of two.

