Skip to content

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

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.middleware for every method and per-method middleware through createHandlers — the same createMiddleware that server functions and src/start.ts use. Handlers take a web-standard Request and return a Response, 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’s requestId() 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.

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.

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 safeParse with 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

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.

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 service
    return 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) and readQuery(url, schema) in server/api/http.ts check Content-Type: application/json (415 otherwise), parse, and validate with the shared Zod schema. Malformed JSON and a failed schema both answer 400 with z.flattenError(error), so every route fails the same way

  • API callers authenticate with a token, never the session cookie. apiCaller, a request middleware in src/start.ts, runs for every versioned path (/api/v…). It passes only the Authorization header to Better Auth — the bearer or API-key plugin, chosen per product (ADR-0009) — puts the caller in context, and answers 401 when 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, whose GET and POST hand the request to auth.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 as POST /api/v1/invoices

  • Tests: integration tests call the handlers in server/api/v1/ with a Request and 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-openapi under 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.

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
  • apiCaller must match every versioned path; a guard test calls a /api/v1/ route with a session cookie and no token and expects 401
  • 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.