Api design
Design and build the site's HTTP API surface — Next.js route handlers with typed Response.json, zod v4 validation at every boundary, one consistent error envelope (stable code + human message), strict status-code discipline, and the decision rule for when server actions beat API routes (internal mutations) and when routes win (external consumers, webhooks, non-browser clients). Invoke during the backend phase whenever the brief needs HTTP endpoints or the user says "API", "endpoint", "REST", "route handler", "webhook", or "expose the data". For the site's own forms mutating its own data, use ultraweb:server-actions instead.From its SKILL.md
npx -y skills add blyatiful1/ultraweb --skill api-designAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
8.2 KB, as published. Nobody here has run it
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
design/BRIEF.md §Backend: needs — "Fleet-tier customers pull berth windows into their own dashboards: a keyable, versioned read API under /api/v1, documented on /docs." A partner's server is a non-browser consumer, so this endpoint wins a route handler over a server action.
GET /api/v1/berths?port=NLRTM&window=7d — searchParams validated with zod v4, Better Auth session gate first:
// app/api/v1/berths/route.ts
const querySchema = z.object({
port: z.string().length(5, { error: "port must be a UN/LOCODE" }),
window: z.enum(["24h", "7d", "30d"]).default("7d"),
});
export async function GET(req: Request) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return apiError(401, "unauthorized", "API key required.");
const parsed = querySchema.safeParse(Object.fromEntries(new URL(req.url).searchParams));
if (!parsed.success) return apiError(400, "validation_failed", parsed.error.issues[0].message);
return Response.json(await listBerths(parsed.data));
}
Rejected a server action for this read: actions are RPC bound to Tidepool's own forms and invisible to a partner's curl — a versioned GET route is the contract they build against. The route lands at app/api/v1/berths/route.ts; the hero's live berth timeline fetches it client-side (ultraweb:data-fetching) and listBerths is the Drizzle+Neon query from ultraweb:database.
Composes with
- ultraweb:server-actions — the default for internal mutations; this skill covers what actions can't
- ultraweb:auth — session checks inside handlers, 401/403 semantics
- ultraweb:database — handlers call the Drizzle query layer, never inline SQL
- ultraweb:payments — the Stripe webhook is a route handler with its own raw-body + signature rules
- ultraweb:data-fetching — RSC reads skip HTTP entirely; consult before adding any GET endpoint
- ultraweb:brief — its §Backend: needs list names every endpoint this skill builds; step 1 reads BRIEF.md and anything not traced there is cut
- ultraweb:storage — its upload token/presign route is a route handler that borrows this skill's error envelope and status-code discipline