Next.js 16 App Router route handler + Zod validation: working versions (tested 2026-10-15)
Validate JSON bodies and search params in a Next.js App Router route handler with Zod, returning typed 400s; tested by calling the handler with NextRequest.
passed3 passed · 0 failed2026-10-15 13:31 UTC- Status
- passed
- Last passed
- 2026-10-15 13:31 UTC
- Tests
- 3 / 3
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38742087302
The code
Exactly the files that passed. Your agent can pull them with get_recipe("nextjs-route-handler-zod") (install), or see them on GitHub.
{
"name": "nextjs-route-handler-zod",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"next": "16.4.1",
"zod": "4.6.7",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "24.19.2"
}
}
import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
const CreateTodo = z.object({ title: z.string().trim().min(1).max(120), done: z.boolean().default(false) });
const ListQuery = z.object({ limit: z.coerce.number().int().min(1).max(50).default(10) });
const todos: { id: number; title: string; done: boolean }[] = [];
export async function POST(req: NextRequest) {
let body: unknown;
try { body = await req.json(); } catch { return NextResponse.json({ error: "invalid_json" }, { status: 400 }); }
const parsed = CreateTodo.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "invalid_body", issues: z.flattenError(parsed.error).fieldErrors }, { status: 400 });
const todo = { id: todos.length + 1, ...parsed.data };
todos.push(todo);
return NextResponse.json(todo, { status: 201 });
}
export async function GET(req: NextRequest) {
const q = ListQuery.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!q.success) return NextResponse.json({ error: "invalid_query" }, { status: 400 });
return NextResponse.json(todos.slice(0, q.data.limit));
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { NextRequest } from "next/server";
import { GET, POST } from "../app/api/todos/route.ts";
const post = (body: string) => POST(new NextRequest("http://localhost/api/todos", { method: "POST", body, headers: { "content-type": "application/json" } }));
test("creates a todo with defaults", async () => {
const res = await post(JSON.stringify({ title: " ship it " }));
assert.equal(res.status, 201);
assert.deepEqual(await res.json(), { id: 1, title: "ship it", done: false });
});
test("400 on bad body and bad JSON", async () => {
const bad = await post(JSON.stringify({ title: "" }));
assert.equal(bad.status, 400);
assert.ok((await bad.json()).issues.title);
assert.equal((await post("{oops")).status, 400);
});
test("validates and coerces query params", async () => {
assert.equal((await GET(new NextRequest("http://localhost/api/todos?limit=1"))).status, 200);
assert.equal((await GET(new NextRequest("http://localhost/api/todos?limit=999"))).status, 400);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Next.js 16 server actions + Zod with useActionState-shaped results
A 'use server' form action that validates FormData with Zod and returns field errors in the shape useActionState expects, tested by calling the action directly.
React Hook Form + Zod 4 resolver schemas (no DOM)
Share one Zod schema between React Hook Form (via @hookform/resolvers) and the server, with cross-field refinements, tested by calling the resolver directly.
React Router 8 data loaders and actions tested with createStaticHandler
Route loaders and actions with redirects, validation errors and 404 responses in React Router's data APIs, tested offline with createStaticHandler and plain Requests.
Anthropic TypeScript SDK tool use loop, tested with a mocked fetch
Run a Claude tool_use loop with the official Anthropic SDK and Zod-validated tool inputs, tested offline with an injected fetch.