← All posts
·6 min read·GuardLayer

How to validate the request body in a Next.js API route (and why request.json() is a hole)

Next.jsValidationZodSecurity

await request.json() returns any. TypeScript stops checking at the network boundary — the moment a payload crosses into your route handler, the compiler shrugs and trusts you. So the line that feels like the safest in your codebase is actually the most dangerous: it's the one place where a stranger on the internet gets to decide the shape of your data.

Input validation is the gap most Next.js apps ship with, because nothing breaks when you skip it. The happy path works. The form submits. The demo passes. Then someone sends a body you never imagined — and your handler runs it anyway.

What "no input validation" actually means

A request body is attacker-controlled. Full stop. The browser is a suggestion; curl is the truth. Anyone can POST any JSON to your route with any keys, any types, any depth. "Validation" means you reject everything that doesn't match a schema you defined — before that data touches your database, your auth logic, or another service.

Here's the canonical version of the bug — an API route that reads the body and uses it directly:

import { NextResponse } from "next/server";
import { db } from "@/lib/db";

export async function POST(request: Request) {
  // Whatever the client sends becomes a comment, exactly as-is.
  const body = await request.json();

  const comment = await db.comment.create({
    data: {
      postId: body.postId,
      authorId: body.authorId,
      content: body.content,
      pinned: body.pinned,
    },
  });

  return NextResponse.json(comment, { status: 201 });
}
guardlayer scan · app/api/comments/route.tsLive engine output
Passed with warnings
92/100 · A
  • Warningapp/api/comments/route.ts:6

    API route without input validation

    Validate the parsed body against a schema (e.g. zod safeParse) before use, and return 400 on failure. Consider rate limiting for unauthenticated routes.

This compiles. It works when you test it. And it hands the client a blank cheque.

Why it's dangerous, concretely

This isn't a style nit. Skipping validation opens several distinct attacks at once:

  • Mass assignment. The handler reads authorId straight from the body, so anyone can post a comment as another user by setting authorId to a different id. The same trick sets pinned: true, or — if the model has them — role, isAdmin, or verified. You're letting the client write columns it should never control.
  • Type confusion and crashes. content is assumed to be a string. Send content as a number, an object, or a 4 MB string and you get 500s, weird coercions, or — depending on the driver — query operators smuggled into the database.
  • Injection downstream. Unvalidated values that flow into a query, a shell call, a file path, or an HTML render are the raw material for SQL injection, SSRF, and stored XSS. Validation is the choke point that stops untrusted data before it spreads.
  • Resource abuse. No length cap on content means a 50 MB comment sails straight in. No type check means deeply nested objects that bog down your JSON parser or ORM.

The throughline: every one of these is "the client sent something I didn't expect, and I used it anyway." A schema makes the unexpected unable to reach your logic.

The fix: parse, don't trust

Validate the parsed body against a schema and bail with a 400 on failure. zod is the standard choice in the Next.js world — define the shape once, get a typed object out the other side:

import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth";

// Only these fields, only these types. Everything else is rejected.
const CommentInput = z.object({
  postId: z.string().uuid(),
  content: z.string().min(1).max(2000),
});

export async function POST(request: Request) {
  const user = await getUser();
  if (!user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const parsed = CommentInput.safeParse(await request.json());
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const comment = await db.comment.create({
    data: {
      postId: parsed.data.postId,
      content: parsed.data.content,
      // authorId comes from the session, NOT the body. No mass assignment.
      authorId: user.id,
      pinned: false,
    },
  });

  return NextResponse.json(comment, { status: 201 });
}

Three things changed, and each closes a hole:

  1. The schema defines the allowed shape. Unknown keys are stripped, content is length-bounded, postId must be a real UUID. Malformed input gets a clean 400 instead of a 500 or silent corruption.
  2. authorId comes from the session, never the body. This is the rule that kills mass assignment: the client never names who it is. Identity is server-derived; the body only carries the data the client legitimately owns.
  3. pinned is set server-side. Privileged fields aren't accepted from input at all.

Prefer safeParse over .parse() in route handlers. .parse() throws, and an unhandled throw becomes an ugly 500. safeParse returns a discriminated result (success: true/false) you can turn into a deliberate 400 with a body that tells the client what was wrong.

A 30-second self-check

Open every file under app/api/**/route.ts and pages/api/** and run two passes — first list the routes that read a body, then narrow to the ones with no validator in sight:

# 1. Routes that read a request body:
grep -rl -e "request.json()" -e "req.body" app/api pages/api

# 2. Of those, the ones that mention NO schema validator
#    (these read untrusted input and use it raw):
grep -Ll -e "safeParse" -e "z\." -e "valibot" -e "yup" -e "joi" \
  $(grep -rl -e "request.json()" -e "req.body" app/api pages/api)

Anything in that second list is a candidate. While you're there, confirm two more things on each route: identity fields (authorId, userId, ownerId) come from the session and not the body, and string fields have a .max(). Those two checks catch the highest-impact cases.

FAQ

TypeScript already types my body — isn't that enough? No. request.json() is typed any, and any annotation you add on top is a compile-time fiction the runtime never enforces. Types describe what you hope arrives; validation verifies what actually arrived. Only the latter survives contact with an attacker.

Do I need this on authenticated routes too? Yes. Auth answers "who is this," not "is this payload well-formed." A logged-in user can still send a malformed or malicious body — and mass-assignment attacks specifically target authenticated routes, because that's where the valuable writes are.

zod, valibot, yup — does it matter which? Not much. Any schema validator that rejects unknown shapes and runs at runtime closes this gap. zod is the most common in Next.js codebases and pairs cleanly with safeParse + a 400. Pick one and apply it consistently.

What about GET routes with query params? Same principle. searchParams and dynamic route segments are attacker-controlled too. Parse them with a schema (z.coerce.number(), an enum for sort fields) before they reach a query.

Is returning the zod error detail safe? error.flatten() is fine — it describes which fields failed, not internal state. Just don't echo back stack traces or raw exception messages, which can leak implementation details.

Validation is the cheapest security control you can add to a Next.js backend: one schema per route, a safeParse, a 400. GuardLayer flags every API route that reads a body without it — and shows you the exact fix inline — on every push.

Catch this before it ships — free

GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.

Keep reading