Next.js Route Handlers fail Zod validation in five mechanically distinct ways that don't surface the same way in Server Actions: FormData returns strings that fail typed schemas without coercion, z.coerce.boolean() returns true for the string "false", reading the body for Zod consumes the raw bytes needed for webhook signature verification, Route Handlers ship without the automatic Origin/Host CSRF check Server Actions get for free, and identity from a URL search param looks routed but isn't.
The Next.js docs make the surface-area difference explicit: Route Handlers are bare Web Request and Response objects [1]. There is no React dispatcher in front, no automatic identity check, no built-in size cap. The validation patterns from the Server Actions + Zod guide cover the pillar boundary; this post covers the five Route-Handler-specific traps that show up the first time you write app/api/*/route.ts in a production codebase.
TL;DR:
FormDatavalues are all strings.request.formData()returnsFormDataEntryValue(string | File). A schema likez.object({ count: z.number() })rejects"5". Usez.coerce.number()for scalars orzod-form-datafor the full shape; the Next.js Route Handlers docs verbatim recommend the latter [1].z.coerce.boolean('false')returnstrue. Zod's coerce callsBoolean(input)directly [4];Boolean('false')istruebecause any non-empty string is truthy [6]. A?published=falsequery flag enables the flag. Fix:z.enum(['true', 'false']).transform(v => v === 'true').- Reading the body for Zod breaks Stripe webhook signature verification. Web
Requestbodies can only be read once.await request.json()invalidates the raw bytesconstructEventneeds. Stripe docs verbatim: "Any manipulation to the raw body of the request causes the verification to fail" [3]. Fix:request.text()once, verify, thenJSON.parse()andsafeParse(). - Route Handlers ship without the Origin/Host CSRF check Server Actions get for free. The Next.js Forms guide documents Server Actions' built-in protection [2]; Route Handlers are public HTTP endpoints with cookies attached. CWE-352 names the consequence [5]. Add an explicit Origin check, or keep state-changing operations on Server Actions.
searchParams.get('userId')is browser-tampered identity, even after Zod. A schema that accepts{ userId: z.string().uuid() }validates the shape, not the provenance. Identity comes fromgetClaims()on the Supabase server client, never from the URL or body, the same rule as Server Actions but a different vector.
Table of contents
- What's different about validation in Route Handlers vs Server Actions?
- Why does FormData need z.coerce or zod-form-data?
- What does z.coerce.boolean() actually return for the string "false"?
- Why does reading the body for Zod break webhook signature verification?
- Why don't Route Handlers get the CSRF protection Server Actions get for free?
- Why is searchParams identity worse than form payload identity?
- What does a hardened Route Handler look like end-to-end?
- What does SecureStartKit ship today, and what should you add?
What's different about validation in Route Handlers vs Server Actions?
Route Handlers and Server Actions parse input through entirely different APIs. A Server Action receives a FormData (or a typed argument from useActionState) inside a React dispatcher that already ran an Origin-vs-Host CSRF check and forwarded the session cookie [2]. A Route Handler receives a bare Web Request object with no validation, no identity check, and no built-in body cap [1]. Same Zod schemas; different threat surfaces around them.
The Next.js Route Handlers reference is explicit about the surface area: "Route Handlers allow you to create custom request handlers for a given route using the Web Request and Response APIs" [1]. Everything that's automatic in a Server Action is your job in a Route Handler.
The five practical consequences:
| Concern | Server Action | Route Handler |
|---|---|---|
| Input parsing | FormData (or typed useActionState arg) | request.json() / .formData() / .text(), your choice |
| Content-type discrimination | Handled by dispatcher | You inspect request.headers.get('content-type') |
| CSRF protection (Origin vs Host) | Automatic [2] | None; you add it or skip state-changing operations |
| Cookie session forwarding | Automatic | Manual via cookies() from next/headers |
| Body size cap | Framework-enforced at the dispatcher | Platform-enforced (Vercel function limits) only |
The implication for code: every Route Handler that mutates state needs four explicit steps the Server Action gets for free, and safeParse is only one of them. Skip any and the endpoint is a public mutation point with a thin Zod wrapper.
Why does FormData need z.coerce or zod-form-data?
request.formData() returns a FormData instance whose values are typed FormDataEntryValue, which is string | File. A Zod schema declared with z.number(), z.boolean(), or z.date() parses against the runtime type of the value, and the runtime type is always string. The schema rejects every numeric and boolean field even when the form submitted valid values. The Next.js Route Handlers docs name the constraint verbatim: "Since formData data are all strings, you may want to use zod-form-data to validate the request and retrieve data in the format you prefer (e.g. number)" [1].
The minimal broken handler:
// app/api/checkout/route.ts (broken)
import { NextResponse } from 'next/server'
import { z } from 'zod'
const schema = z.object({
priceId: z.string().min(1).max(64),
quantity: z.number().min(1).max(99), // rejects "1"
})
export async function POST(request: Request) {
const formData = await request.formData()
const parsed = schema.safeParse({
priceId: formData.get('priceId'),
quantity: formData.get('quantity'),
})
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
// never reached on real submissions
}
Two fixes, each appropriate to a different scope.
For one or two scalar fields, use z.coerce. Zod's coerce wrappers call the JavaScript constructor on the input: z.coerce.number() runs Number(input), z.coerce.bigint() runs BigInt(input), and so on [4]. The schema becomes:
const schema = z.object({
priceId: z.string().min(1).max(64),
quantity: z.coerce.number().int().min(1).max(99),
})
This works for number, bigint, and string. It does not work for boolean (see the next failure mode) and the API does not document a z.coerce.date() [4]; date fields parse via z.iso.date().transform(s => new Date(s)) or a custom transform.
For a multi-field form with nested shapes, use zod-form-data. The library exports a zfd.formData(schema) wrapper that walks the FormData and applies type-aware coercion per field, including arrays for repeated keys and File for binary inputs [7]. The Next.js docs treat it as the canonical recommendation for non-trivial form handlers [1].
import { zfd } from 'zod-form-data'
import { z } from 'zod'
const schema = zfd.formData({
priceId: zfd.text(z.string().min(1).max(64)),
quantity: zfd.numeric(z.number().int().min(1).max(99)),
attachments: zfd.repeatable(z.array(zfd.file())),
})
export async function POST(request: Request) {
const formData = await request.formData()
const parsed = schema.safeParse(formData)
// ...
}
The same rule applies to request.nextUrl.searchParams. The URLSearchParams interface returns string | null from .get(), so a schema like { page: z.number() } rejects every URL. z.coerce.number() is the standard fix, with the gotcha covered in the next section. If you're scaffolding a schema from a known JSON shape, the free JSON-to-Zod tool produces the structural skeleton and you layer the coercion on top.
What does z.coerce.boolean() actually return for the string "false"?
z.coerce.boolean().parse('false') returns true. This surprises every developer who hits it the first time and is the bug behind a non-trivial number of "feature flag enabled by accident" production incidents. Zod's coerce wrappers call the JavaScript constructor [4], and the JavaScript constructor Boolean(input) returns true for any non-empty string [6]. 'false', 'no', '0', ' ': all truthy.
The Zod API docs show the implementation directly:
z.coerce.boolean(); // Boolean(input)[4]
Combined with MDN's definition: "The Boolean object is an object wrapper for a boolean value. ... Any object whose value is not undefined or null, including a Boolean object whose value is false, evaluates to true when passed to a conditional statement" [6]. The 'false' string is non-empty; therefore Boolean('false') is true; therefore z.coerce.boolean().parse('false') is true.
The handler that ships the bug:
// app/api/posts/route.ts
const schema = z.object({
published: z.coerce.boolean(), // accepts "?published=false" → true
})
export async function GET(request: NextRequest) {
const sp = request.nextUrl.searchParams
const parsed = schema.safeParse({ published: sp.get('published') })
if (parsed.success && parsed.data.published) {
return NextResponse.json(await getPublishedPosts())
}
return NextResponse.json(await getDraftsForCurrentUser())
}
A request to /api/posts?published=false returns published posts. A request to /api/posts?published=0 returns published posts. A request to /api/posts (no param) returns drafts, which is the only case that behaves as the field name suggests.
Three fixes, ranked by explicitness.
Fix 1 (explicit enum + transform, recommended). Constrain to the literal strings you accept, then transform:
const flag = z
.enum(['true', 'false'])
.transform((v) => v === 'true')
const schema = z.object({
published: flag.optional().default(false),
})
This rejects ?published=yes, ?published=1, and any other variant. The schema's runtime type stays boolean, the failure mode disappears, and the error message tells the client exactly which string it sent that wasn't accepted.
Fix 2 (string compare without coerce). If you want to accept a wider set of truthy strings:
const truthy = new Set(['true', '1', 'yes', 'on'])
const schema = z.object({
published: z
.string()
.optional()
.transform((v) => (v ? truthy.has(v.toLowerCase()) : false)),
})
Fix 3 (preprocess for backwards compat with JSON bodies). When the same handler accepts both JSON bodies (real boolean values) and form-encoded strings:
const schema = z.object({
published: z.preprocess((v) => {
if (typeof v === 'boolean') return v
if (typeof v === 'string') return v.toLowerCase() === 'true'
return false
}, z.boolean()),
})
The same trap exists for z.coerce.number() against searchParams: Number('') is 0, so a missing query param coerces to a valid zero. Use .min(1) aggressively or pair the coerce with .optional() to distinguish absence from zero.
Why does reading the body for Zod break webhook signature verification?
A Web Request body can only be read once. The first call to request.json(), request.text(), or request.formData() consumes the underlying ReadableStream; subsequent calls throw or return empty. For an authenticated POST, this is fine. For a webhook signature verification, it is fatal.
Stripe's webhook docs name the constraint verbatim: "Stripe requires the raw body of the request to perform signature verification. ... Any manipulation to the raw body of the request causes the verification to fail" [3]. Stripe.webhooks.constructEvent(payload, signature, secret) recomputes the HMAC-SHA256 of payload; the slightest divergence from the bytes Stripe signed makes the verification fail. JSON.parse followed by JSON.stringify is divergence: key order, whitespace, number formatting (1 vs 1.0), Unicode escape choices. The reserialized string is not the signed string.
The Route Handler that ships the bug looks correct in isolation:
// app/api/webhooks/stripe/route.ts (broken)
export async function POST(request: Request) {
const body = await request.json() // consumes raw bytes
const sig = (await headers()).get('stripe-signature')!
// attempt to "rebuild" the body for verification
const event = stripe.webhooks.constructEvent(
JSON.stringify(body), // not the original bytes
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
// throws Webhook signature verification failed on every call
}
The fix is order-sensitive: read the body once as text, verify, parse, then validate with Zod. The shipped SecureStartKit handler at app/api/webhooks/stripe/route.ts is the worked example:
// app/api/webhooks/stripe/route.ts (correct, as shipped)
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import { getStripe } from '@/lib/stripe/client'
export async function POST(request: Request) {
const body = await request.text() // read once, as raw text
const headersList = await headers()
const sig = headersList.get('stripe-signature')
if (!sig) {
return NextResponse.json({ error: 'No signature' }, { status: 400 })
}
let event: Stripe.Event
try {
event = getStripe().webhooks.constructEvent(
body, // verbatim bytes that Stripe signed
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
// Only now is it safe to layer Zod on the parsed event
// ... switch (event.type) ...
}
A second Zod layer can validate the event's data shape after constructEvent succeeds, but the temptation to validate before verification breaks the chain. The signature check is the trust boundary; Zod is the schema boundary. Order matters.
The full handler for production needs idempotency (event-id dedup with a unique constraint), retry tolerance (Stripe retries failed deliveries for three days), and timestamp tolerance configuration. The Stripe webhook signature verification deep-dive covers each at length. For testing the same handler locally without Stripe CLI, the free Stripe webhook verifier tool walks the verification math step by step against a real payload.
This is the cleanest cross-link to the pre-launch security audit Check 5: every webhook endpoint must show the raw-body read happening before any parser touches the bytes. If your audit finds await request.json() followed by constructEvent(JSON.stringify(...)), that handler has never successfully verified a webhook in production. The retries succeeded because the handler returned 200 on the second attempt after the first failed; the verification never actually ran.
Why don't Route Handlers get the CSRF protection Server Actions get for free?
Server Actions run inside a React Server Function dispatcher that automatically rejects requests whose Origin header does not match the request's Host. The Next.js Forms guide documents the protection: Server Actions get Origin-vs-Host CSRF defense baked into the dispatcher [2]. Route Handlers are bare Web API endpoints; the dispatcher is not in front of them. A cross-origin browser can POST to /api/checkout with the user's cookie attached and the handler will run.
CWE-352 names the consequence verbatim: "The web application does not, or cannot, sufficiently verify whether a request was intentionally provided by the user who sent the request, which could have originated from an unauthorized actor" [5]. The mitigations CWE lists are Origin/Referer header validation, a synchronizer token, and SameSite cookie attributes [5]. Route Handlers get none of these unless you wire them in. The CSRF, XSS, and SQL injection guide covers the three-layer architecture for the application surface; this section covers the specific Route Handler check.
Two practical fixes.
Fix 1: explicit Origin check on every state-changing Route Handler. This is the same check Server Actions do automatically, lifted into your handler:
// lib/csrf.ts
import { headers } from 'next/headers'
export async function assertSameOrigin() {
const h = await headers()
const origin = h.get('origin')
const host = h.get('host')
if (!origin || !host) {
throw new Response('Missing origin', { status: 403 })
}
const originHost = new URL(origin).host
if (originHost !== host) {
throw new Response('Origin mismatch', { status: 403 })
}
}
// app/api/checkout/route.ts
import { assertSameOrigin } from '@/lib/csrf'
export async function POST(request: Request) {
await assertSameOrigin() // throws Response on mismatch
const body = await request.json()
const parsed = checkoutSchema.safeParse(body)
// ... continue
}
The check rejects cross-origin POSTs from any other domain. Combined with SameSite=Lax (or Strict) on the session cookie, the cookie does not get attached to cross-origin POSTs in the first place, which is defense in depth.
Fix 2: keep state-changing operations on Server Actions. If a Route Handler is doing something a Server Action could do (checkout creation, profile update, settings mutation), the Server Action is the safer surface. Route Handlers earn their place for webhooks, OAuth callbacks, public APIs called by your own native clients, and read-only endpoints with bespoke caching. Anything mutating state inside the same SPA that already uses Server Actions probably should not be a Route Handler.
The Next.js security hardening checklist covers the broader perimeter; the Route-Handler-specific rule reduces to: if the handler mutates state and accepts a session cookie, add the Origin check or move it to a Server Action.
Why is searchParams identity worse than form payload identity?
The pillar rule applies the same way: identity comes from the validated session, never from the payload, even after safeParse [2]. The Route Handler version is worse because the URL pattern actively invites the mistake. A handler at app/api/orders/[id]/route.ts with params.id and ?userId= looks routed; the user is "in their own orders URL." The route param and search param both came from the browser address bar. Neither is identity.
The broken handler:
// app/api/orders/[id]/route.ts (broken)
const schema = z.object({
userId: z.string().uuid(),
status: z.enum(['pending', 'shipped', 'delivered']),
})
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = await request.json()
const parsed = schema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
// Both `id` and `parsed.data.userId` are browser-controlled
await admin
.from('orders')
.update({ status: parsed.data.status })
.eq('id', id)
.eq('user_id', parsed.data.userId) // trusted from body
}
A user can PATCH /api/orders/<someone-elses-order-id> with { userId: '<their-own-uuid>', status: 'delivered' } and the eq('user_id', parsed.data.userId) filter passes their own id, not the order's actual owner. The query updates someone else's order. Zod validated that userId is a UUID; it did not validate that the UUID belongs to the caller.
The fix is the same as the pillar's: identity from getClaims(), never from input:
// app/api/orders/[id]/route.ts (correct)
import { assertSameOrigin } from '@/lib/csrf'
import { getCurrentClaims, createAdminClient } from '@/lib/supabase/server'
const schema = z.object({
status: z.enum(['pending', 'shipped', 'delivered']),
})
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
await assertSameOrigin()
const { id } = await params
const body = await request.json()
const parsed = schema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
// Identity from the validated session, never from input
const claims = await getCurrentClaims()
if (!claims) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const admin = createAdminClient()
const { error } = await admin
.from('orders')
.update({ status: parsed.data.status })
.eq('id', id)
.eq('user_id', claims.sub) // identity from JWT, server-validated
if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
return NextResponse.json({ ok: true })
}
The schema shrinks: there is no userId field because there is no legitimate reason to accept one. The Server Actions pillar made this rule architectural; the Route Handler version layers it on top of the Origin check and the cookie session read. Both are the backend-only data access pattern applied at a different surface: validate, identify, then query through the admin client. The schema validates parsed.data; the session identifies the caller; the admin client runs the query with user_id = claims.sub as the authorization predicate, never with a value from input.
What does a hardened Route Handler look like end-to-end?
The four steps a Server Action gets automatically, written out for a Route Handler:
// app/api/profile/route.ts (full hardened pattern)
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { assertSameOrigin } from '@/lib/csrf'
import { getCurrentClaims, createAdminClient } from '@/lib/supabase/server'
import { rateLimit } from '@/lib/rate-limit'
const profileSchema = z.object({
fullName: z.string().min(2).max(80),
bio: z.string().max(280).optional(),
})
export async function PATCH(request: Request) {
// 1. CSRF: explicit Origin/Host check (Server Actions get this free)
try {
await assertSameOrigin()
} catch (response) {
return response as Response
}
// 2. Identity: from validated session, never from input
const claims = await getCurrentClaims()
if (!claims) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 3. Rate limit: per-caller, fail open or closed deliberately
const rl = await rateLimit('profile-update', 10, 60, claims.sub)
if (!rl.success) {
return NextResponse.json({ error: 'Rate limited' }, { status: 429 })
}
// 4. Validation: read body, parse, reject early
const body = await request.json()
const parsed = profileSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.flatten().fieldErrors },
{ status: 400 }
)
}
// 5. Privileged work, identity from session, data from parsed.data
const admin = createAdminClient()
const { error } = await admin
.from('profiles')
.update({
full_name: parsed.data.fullName,
bio: parsed.data.bio,
})
.eq('id', claims.sub) // user_id from JWT, not from body
if (error) {
return NextResponse.json({ error: 'Update failed' }, { status: 500 })
}
return NextResponse.json({ ok: true })
}
Five steps in fixed order. The Server Action version of the same handler is two: the dispatcher does steps 1 and 2 for you; rate-limit is the same; validation is the same; the privileged work is the same. The cost of moving a mutation from a Server Action to a Route Handler is exactly those three lines per handler, and the same five proxy.ts route protection failure modes that show up around middleware apply equally to the auth check inside this handler.
For webhook endpoints the pattern is different (signature verification replaces CSRF and identity comes from the verified event, not a session), and for public read-only endpoints some steps drop, but the principle holds: every step Server Actions handles automatically is your job in a Route Handler.
What does SecureStartKit ship today, and what should you add?
The template's Route Handler usage is intentionally narrow: the Stripe webhook handler at app/api/webhooks/stripe/route.ts and a small number of OAuth and metadata routes. Every mutation that could be a Server Action is a Server Action. That choice maps directly to this post's failure modes:
| Failure mode | Why SecureStartKit avoids it today |
|---|---|
| FormData coercion | Forms use Server Actions with typed useActionState; no Route Handler form parsing |
z.coerce.boolean('false') truthy | No public Route Handlers consume boolean query params |
| Webhook raw-body conflict | The Stripe handler reads request.text() before constructEvent; verification runs against the exact signed bytes [3] |
| Missing CSRF check | State-changing operations live in Server Actions, which get Origin/Host automatically [2] |
Identity from searchParams | Server Actions read identity via getClaims() on the Supabase server client; no Route Handler accepts a userId field |
What this means in practice: the template is the conservative end of the spectrum. If your project needs a public REST API, native-mobile-client endpoints, or a callback surface for an OAuth provider you're integrating, you're going to write more Route Handlers than the template ships. The five failure modes above are the ones that show up the moment you do.
The honest version of the architecture trade-off: Server Actions are not always the right answer. Public APIs need Route Handlers. Webhooks need Route Handlers. Long-running streaming endpoints need Route Handlers. The pattern is to keep Server Actions for in-app form submissions and use Route Handlers deliberately for everything that genuinely requires a public HTTP surface, with the four-step harness above wrapping each one. Skip the harness on any single handler and the endpoint is a safeParse call surrounded by attack surface.
If you want this pre-wired (Server Actions for in-app mutations, the four-step harness on every Route Handler, raw-body discipline on the Stripe webhook handler, identity from getClaims() on every privileged read), that is what SecureStartKit is. The site you are reading uses exactly these patterns for every form, every webhook, and every authenticated endpoint.
Built for developers who care about security
SecureStartKit ships with these patterns out of the box.
Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.
References
- File-system conventions: route.js— nextjs.org
- How to create forms with Server Actions— nextjs.org
- Receive Stripe events in your webhook endpoint— docs.stripe.com
- Zod API: coerce, schemas, validators— zod.dev
- CWE-352: Cross-Site Request Forgery (CSRF)— cwe.mitre.org
- Boolean, JavaScript reference— developer.mozilla.org
- zod-form-data— npmjs.com
Related Posts
Server Actions + Zod in Next.js 16: Validate Every Input
Server Actions are public HTTP endpoints. Validate every payload with Zod before any database call. Patterns for Next.js 16 and Zod 4 with CVE context.
Next.js Environment Variables: 6 Leak Modes [2026]
Six Next.js environment variable leak modes (NEXT_PUBLIC drift, middleware fallthrough, build-time inlining, Vercel scope) and the architectural fixes.
5 Production Rate-Limit Failure Modes in Next.js [2026]
Five production rate-limit failure modes for Next.js Server Actions: XFF off Vercel, fixed-window burst, distributed IPs, missing await, billing.