ProvenPin Install

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
pinned versions6
next@16.4.1zod@4.6.7react@19.3.1react-dom@19.3.1tsx@4.23.15@types/node@24.19.2
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
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.

package.json
{
  "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"
  }
}
app/api/todos/route.ts
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));
}
test/route.test.ts
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.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.