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 ifemailis missing or is a number. A bad request becomes a500and an error in your logs. -
Corrupt data.
age: "banana"gets stored, or silently becomesnull, and breaks something three screens later. -
Privilege escalation. If
roleexists 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,planandpriceusually 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. -
safeParsereturns a result instead of throwing, so you decide the response. - Bad input gets a
400with the reasons, not a500. -
parsed.datacontains only the fields in the schema. By default, Zod object schemas strip keys they do not know, soroleis gone before it reaches the database. (Zod's API details differ a little between major versions, so check the docs for yours.error.issuesis 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.envonce 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 SomeTypeon anything fromreq.json(),JSON.parseorfetch -
Model.create(body)orModel.update(..., body)or{ ...body }going into a database call -
Number(searchParams.get(...))with no check forNaN -
await req.json()with notry/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
Top comments (0)