
Next.js · TypeScript
Type-Safe API Layers with App Router and Zod
December 2023 · 10 min read
TypeScript generics alone do not protect a Next.js App Router product. Runtime data from browsers, WebViews, and partner APIs will still arrive wrong. The pattern we standardized — especially on enrollment and admin tools — is simple: parse at the edge, trust inward.
The boundary stack
- Zod schemas for query, body, and route params
- Shared DTO packages (or modules) imported by UI and server
- Result types for expected domain failures vs unexpected 500s
- Client hooks (TanStack Query) that only see parsed outputs
App Router specifics
Server Actions and route handlers both benefit from the same schema. We avoid duplicating “form types” and “API types.” One schema drives React Hook Form resolvers on the client and safeParse on the server. Invalid payloads never reach Prisma.
const EnrollmentInput = z.object({
sheba: z.string().regex(/^IR\d{24}$/),
planId: z.string().cuid(),
});
// server
const parsed = EnrollmentInput.safeParse(payload);
if (!parsed.success) return NextResponse.json(parsed.error.format(), { status: 400 });
Errors as a product surface
Typed success paths are easy; typed failures are what seniors get paid for. We map Zod issues to field errors the wizard can highlight, and map domain conflicts (duplicate enrollment, expired session) to stable error codes the UI can translate for FA/EN.
Where people overdo it
- Generating giant types from OpenAPI and never validating runtime
- Putting Prisma models directly into the client bundle
- Throwing raw Error strings across Server Actions without codes
Adoption checklist
- Identify every external input (forms, search params, webhooks).
- Create schemas first; wire UI second.
- Log parse failures with request IDs in production.
- Refuse to merge features that bypass
safeParse“just for now.”
Type-safe API layers are not about prettier autocomplete. They are about making invalid states unrepresentable and unexecutable.