You write a clean endpoint. The body is typed. The editor is happy. No red squiggles anywhere.

type CreateUser = { name: string; email: string; age: number }; export async function POST(req: Request) { const body = (await req.json()) as CreateUser; await User.create(body); return Response.json({ ok: true }); } 

Then someone sends this:

{ "name": "Mallory", "email": "m@example.com", "age": "banana", "role": "admin" } 

TypeScript says age is a number. It is a string. TypeScript says there is no role. There is one, and it just got saved.

Types describe what you hope arrives. They do not check what actually arrived.

Why This Happens

TypeScript types exist only while you write and build the code. They are erased before the code runs. At runtime there is no CreateUser, only a JavaScript object that anyone on the internet shaped however they liked.

as CreateUser does not convert or check anything. It tells the compiler "trust me", and the compiler obeys. The same is true of the req.json() result typed as any and of data from fetch, query strings, form submissions, webhooks and even your own database if the schema has drifted.

The rule: anything that crosses the edge of your program is unknown, whatever the type says.

What Goes Wrong

Three real failures from that one line:

  • Crashes. body.email.toLowerCase() throws if email is missing or is a number. A bad request becomes a 500 and an error in your logs.
  • Corrupt data. age: "banana" gets stored, or silently becomes null, and breaks something three screens later.
  • Privilege escalation. If role exists in your schema, User.create(body) saves it. This is called mass assignment: the client sets fields you never meant it to set. Mongoose drops fields that are not in the schema, but that only helps for fields that do not exist. role, isAdmin, plan and price usually do.

Also, await req.json() itself throws on invalid JSON. If you do not catch that, a malformed body is a server error instead of a 400.

The Fix: Validate at the Boundary

Use a schema library that checks the data at runtime and gives you the type as a bonus. Zod is the common choice in the TypeScript world:

import { z } from 'zod'; const CreateUser = z.object({ name: z.string().min(1).max(80), email: z.string().email(), age: z.number().int().min(13).max(120), }); type CreateUser = z.infer<typeof CreateUser>; // type comes from the schema 

Now the schema is the single source of truth. The type cannot drift from the validation, because it is generated from it.

Use it in the route handler:

export async function POST(req: Request) { let json: unknown; try { json = await req.json(); } catch { return Response.json({ error: 'Invalid JSON' }, { status: 400 }); } const parsed = CreateUser.safeParse(json); if (!parsed.success) { return Response.json( { error: 'Invalid input', issues: parsed.error.issues }, { status: 400 } ); } await User.create(parsed.data); // only the fields in the schema return Response.json({ ok: true }, { status: 201 }); } 

What changed:

  • The body is typed as unknown, which forces a check before use.
  • safeParse returns a result instead of throwing, so you decide the response.
  • Bad input gets a 400 with the reasons, not a 500.
  • parsed.data contains only the fields in the schema. By default, Zod object schemas strip keys they do not know, so role is gone before it reaches the database. (Zod's API details differ a little between major versions, so check the docs for yours. error.issues is the part I rely on.)

The best part is that role: "admin" cannot get in because the schema never mentions it. You allow fields, you do not try to block them.

Trap 1: Query Strings Are Always Strings

/api/items?page=2 gives you the string "2", not the number 2. The tempting fix is coercion:

const Query = z.object({ page: z.coerce.number().int().min(1).default(1), }); 

z.coerce.number() works by calling Number() on the value. Which has a surprise:

Number('') // 0 Number(' ') // 0 

An empty ?page= becomes 0, not an error. Here the .min(1) catches it, but for a field without a minimum, an empty string quietly turns into zero. Always add bounds to coerced numbers, and think about what an empty value should mean.

Trap 2: Coerced Booleans Are Almost Always Wrong

z.coerce.boolean().parse('false'); // true 

Boolean('false') is true, because any non-empty string is truthy in JavaScript. A query like ?active=false turns on the thing you meant to turn off.

For query-string booleans, be explicit:

const BoolParam = z.enum(['true', 'false']).transform((v) => v === 'true'); 

Validate More Than the Body

Everything that arrives from outside gets the same treatment:

  • Query strings and URL params, as shown above.
  • Webhooks. After you verify the signature, validate the payload shape too. Providers add and change fields.
  • Environment variables. Parse process.env once at startup with a schema, so a missing key fails the deploy instead of failing a user at 2am.
  • Responses from other APIs, if you depend on their shape.

And remember: validation on the client is for user experience. Anyone can skip your form and call the API directly, so the server check is the one that counts.

What Validation Does Not Do

A schema checks shape: is this a string, is it an email, is the number in range. It does not check permission. "Is this user allowed to change this record?" is a separate check that belongs in your code. Validation says the request is well-formed. Authorization says the person may make it. You need both.

A Five-Minute Audit

Open your API routes and search for these patterns:

  • as SomeType on anything from req.json(), JSON.parse or fetch
  • Model.create(body) or Model.update(..., body) or { ...body } going into a database call
  • Number(searchParams.get(...)) with no check for NaN
  • await req.json() with no try/catch

Each hit is a spot where outside data is trusted. Start with routes that write to the database, because those are where bad data and privilege escalation live.

The Rule

Treat everything that crosses your boundary as unknown, and make a schema turn it into a type. Do not cast it, do not spread it into the database, and allow only the fields you listed.

I build SaaS products with Next.js, and checks like this sit at the top of every route handler, before any business logic. If you want to see the kind of work I do, or a typed starting point for your own project, you can browse my templates on pixelanas.com.

Get the templates: https://pixelanas.gumroad.com

What is the worst thing you have ever seen get saved through an unvalidated request body? Tell me in the comments.

Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751