Never trust data from the client. Validate request bodies and query parameters, reject bad input with a clear 400, and use a schema library like Zod to keep it tidy and typed.
Anything sent by a client can be missing, the wrong type, or hostile — a typo, a buggy mobile app, or someone poking at your API on purpose. Validation is the gate at the front door: check the shape of the data before any business logic or database call touches it. The payoff is twofold — you return a helpful 400 instead of a confusing 500, and you stop bad or malicious data before it reaches your database.
// Without validation, this trusts the client completely:
export async function POST(request: Request) {
const body = await request.json()
await db.users.create({ age: body.age }) // what if age is "banana"?
}
// The fix is a gate: confirm the data is what you expect FIRST,
// and bail out with 400 the moment it is not — before the db call.Hand-writing if-checks for every field gets long and easy to get wrong. A schema library lets you describe the shape once and validate against it. Zod is the common choice in TypeScript: you declare the rules, call safeParse, and get back either clean, typed data or a list of exactly what was wrong. Install it with your package manager, then reuse one schema for every endpoint that touches that resource.
$ pnpm add zod// app/api/users/route.ts
import { z } from 'zod'
const NewUser = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive().optional(),
})
export async function POST(request: Request) {
const json = await request.json().catch(() => null)
const result = NewUser.safeParse(json)
if (!result.success) {
// result.error lists every field that failed and why.
return Response.json(
{ error: 'Validation failed', issues: result.error.issues },
{ status: 400 }
)
}
const user = result.data // fully typed: { name, email, age? }
// ...safe to use now...
return Response.json({ data: user }, { status: 201 })
}Input is not only the body. Anything after the ? in the URL — the "query string," used for things like ?limit=20&active=true — is also client input, and it always arrives as text. Read it from request.nextUrl.searchParams, then coerce and validate it the same way. Coercing matters: "20" is a string until you turn it into the number 20, and an unchecked limit is how someone asks your database for a million rows at once.
// GET /api/users?limit=20&active=true
import type { NextRequest } from 'next/server'
import { z } from 'zod'
// z.coerce turns the incoming text into the right type, then checks it.
const Query = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
active: z.coerce.boolean().optional(),
})
export async function GET(request: NextRequest) {
const parsed = Query.safeParse(
Object.fromEntries(request.nextUrl.searchParams)
)
if (!parsed.success) {
return Response.json({ error: 'Invalid query' }, { status: 400 })
}
const { limit, active } = parsed.data // numbers and booleans, not text
// ...use limit (capped at 100) and active to fetch...
return Response.json({ data: [], page: { limit } })
}