api-route-scaffold
Use when adding a new Next.js App Router API route under services/app/src/app/api/** — new endpoint file (route.ts), new HTTP method on an existing route, or a new list/GET endpoint needing pagination
SKILL.md.claude/skills/api-route-scaffold/SKILL.md
GitHubAPI Route Scaffold
Overview
All 46+ route files under services/app/src/app/api/**/route.ts follow one shape:
handleRoute(async () => { requireRole/requireSession; parseJsonBody/parseQuery; call the domain module; return ok/created/noContent }). handleRoute (in services/app/src/lib/http.ts)
is the ONLY place that translates errors to the API's standard {code, message, details, trace_id} envelope (api/openapi/_common.yaml) — domain code never formats an HTTP response
itself. Deviating from this (hand-rolled try/catch, skipping requireRole, validating with raw
await req.json()) reintroduces exactly the bug QA finding H-1 fixed: an uncaught Prisma P2025
("no record found") falling through to a generic 500 instead of the documented 404.
The Pattern (evidence: stock/entries/route.ts, products/route.ts)
import { z } from "zod";
import { requireRole } from "@/modules/auth";
import { doTheThing } from "@/modules/<domain>";
import { created, handleRoute, ok, parseJsonBody, parseQuery } from "@/lib/http";
const BodySchema = z.object({ /* zod schema mirroring api/openapi/<domain>.yaml */ });
export async function POST(req: Request): Promise<Response> {
return handleRoute(async () => {
const ctx = await requireRole("<domain>:write"); // throws 403 if role lacks permission
const input = await parseJsonBody(req, BodySchema); // throws 400 ValidationError on bad JSON/shape
const result = await doTheThing(ctx.tenantId, ctx.userId, input);
return created(result); // 201; use ok() for 200, noContent() for 204
});
}
Never write your own try { ... } catch { return Response.json(...) } — handleRoute already:
maps any AppError subclass (NotFoundError, ValidationError, ForbiddenError,
UnauthorizedError, ...) to its documented status/body; maps Prisma's P2025
(findUniqueOrThrow/findFirstOrThrow on an RLS-invisible or missing row) to NotFoundError
automatically — this is why domain code is free to use findUniqueOrThrow directly instead of
manual null-checks; logs genuinely unexpected errors server-side with a trace_id (never leaked
to the client) so failures are traceable instead of silent 500s.
List Endpoints: Cursor Pagination Convention
Every GET list endpoint (evidence: catalog/products.ts listProducts,
catalog/suppliers.ts) uses the same Prisma cursor idiom, never offset/page-number pagination:
// Route: query schema
const ListQuerySchema = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).optional(), // ALWAYS clamp -- see HI-1 below
// ...domain filters
});
// Module: query
const limit = filters.limit ?? 20;
const rows = await tx.someModel.findMany({
where: { /* ... */ },
orderBy: [{ createdAt: "desc" }, { id: "desc" }], // tie-break on id -- required for a stable cursor
take: limit + 1, // fetch one extra to detect has_more
...(filters.cursor ? { cursor: { id: filters.cursor }, skip: 1 } : {}),
});
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
return { items: page, page: { next_cursor: hasMore ? (page.at(-1)?.id ?? null) : null, has_more: hasMore } };
If the list needs a derived value per row (e.g. current stock), batch it with ONE query over the
whole page's ids (see getCurrentStockForProducts in stock-operation-scaffold), never one
query per row in a loop — that exact per-row loop was code-reviewer finding HI-1 across four
endpoints, precisely because a hand-written list endpoint skipped this convention.
Common Mistakes
| Mistake | Fix |
|---|---|
Custom try/catch instead of handleRoute |
Always wrap the whole handler body in handleRoute(async () => { ... }) |
Calling the domain function before requireRole/requireSession |
Auth check is always the FIRST line inside handleRoute's callback |
Validating body with raw await req.json() |
Use parseJsonBody(req, ZodSchema) — gets the standard 400 ValidationError shape for free |
Unbounded limit on a list endpoint |
z.coerce.number().int().min(1).max(100).optional() — an unclamped limit turns a list endpoint into an unbounded query |
Per-row query in a .map/loop over a list result |
Batch with WHERE id = ANY($1)-style query over the whole page, per HI-1 |
| New AppError-like condition without a subclass | Add/reuse an AppError subclass in libs/shared so handleRoute/toErrorResponse map it automatically — don't format Response.json by hand in domain code |