api-design — boring contracts, typed boundaries
Stage: Phase 7 (Backend) - Reads: design/BRIEF.md, db/schema.ts (when database ran) - Writes: app/api/**/route.ts, lib/api.ts
Standard
A first-grade API is boring to consume: every response typed, every error the same shape, every status code meaning what RFC 9110 says. A consumer could write their client from three example responses. Every route traces to a line in BRIEF.md — zero endpoints "just in case".
First decision: route handler or not
| Consumer | Tool |
|---|---|
| Your own forms/buttons mutating your data | Server action (ultraweb:server-actions) — typed end-to-end, progressive enhancement, no public surface |
| External services calling in (Stripe/Resend webhooks) | Route handler — they need a URL |
| Non-browser clients (mobile app, partner, CLI) | Route handler — they need HTTP |
| Client components fetching on interaction (typeahead, infinite scroll) | Route handler GET |
| An RSC that needs data | Neither — query the database directly (ultraweb:data-fetching) |
Default: server actions for mutations, direct queries for reads. Route handlers only where HTTP itself is the requirement.
Process
- List every endpoint BRIEF.md demands: method, path, auth requirement, request/response shape. Empty list → this skill doesn't run.
- Create
lib/api.tswith the envelope helpers below. Every handler imports it — no handler invents its own error shape. - Define zod v4 schemas per endpoint (in the route file;
lib/validators.tswhen shared with a server action). Validate everything that crosses the boundary: body, searchParams, dynamic params. - Write handlers.
await params— dynamic segments are Promises in Next 16. - Auth-gate protected routes first thing in the handler (session via
ultraweb:auth); return 401 before doing any work. - Exercise every endpoint with curl: happy path, invalid body, missing auth. Three real responses per endpoint, recorded, before calling it done.
The envelope
Success returns the resource directly. Failure returns ONE shape, always:
// lib/api.ts
export type ApiError = { error: { code: string; message: string } };
export function apiError(status: number, code: string, message: string) {
return Response.json({ error: { code, message } } satisfies ApiError, { status });
}
code is a stable machine string (validation_failed, unauthorized, not_found, conflict, rate_limited, internal); message is written for humans in the site's voice. Never leak stack traces, zod issue arrays, or SQL into message.
A worked handler
// app/api/posts/[id]/route.ts
import { z } from "zod";
import { apiError } from "@/lib/api";
import { auth } from "@/lib/auth";
const patchSchema = z.object({
title: z.string().min(3, { error: "Title needs at least 3 characters" }).optional(),
published: z.boolean().optional(),
});
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return apiError(401, "unauthorized", "Sign in required.");
const { id } = await params;
const json = await req.json().catch(() => null);
const parsed = patchSchema.safeParse(json);
if (!parsed.success)
return apiError(400, "validation_failed", parsed.error.issues[0].message);
const post = await updatePost(id, parsed.data, session.user.id);
if (!post) return apiError(404, "not_found", "Post not found.");
return Response.json(post satisfies Post);
}
Load-bearing details: zod v4 customizes errors with { error: "..." } — the message param is deprecated. .catch(() => null) on req.json() turns malformed JSON into a clean 400 instead of a 500. satisfies types the response at compile time with zero runtime cost.
Status codes
- 200 read/update ok · 201 created (return the created resource) · 204 deleted (empty body)
- 400 validation failed or malformed JSON · 401 no valid session · 403 valid session, forbidden resource · 404 absent (also for resources the caller must not learn exist) · 409 uniqueness/state conflict · 429 rate limited, with
Retry-Afterheader - 500 only for genuine bugs. Wrap handler bodies so an unexpected throw returns
apiError(500, "internal", ...)— never Next's default error page as an API response. - Never 200-with-error-body. Never 500 for bad input.
Rate-limit hook points
Every public unauthenticated POST (contact, newsletter, sign-up) gets a rate-limit check before parsing. Ship the seam even as a stub — an early checkRateLimit(ip) in lib/api.ts returning 429. Which limiter backs it: verify against current docs first; STACK.md pins none.
Anti-patterns
middleware.ts— Next 16 renamed itproxy.ts; and proxy checks are optimistic, never API authorizationsuccess: false,{ err:,{ errorMessage:— a second error shape anywhereNextApiRequest,res.status(— Pages Router relics; handlers speak WebRequest/Responseparams.idwithoutawait— params are Promisesparsed.error.messagesent raw to clients — zod's aggregate string is an internal artifactfetch("/api/from an RSC in the same app — self-HTTP round trip; call the query function directlyapp/api/[...slug]catch-all re-implementing Express inside Next
Worked example — Tidepool, versioned read API for Fleet customers
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.