Never trust input. Every byte from a client is a guess until you've validated it. Validation is the front door to all your CRUD writes.
1import { z } from "zod";
2
3export const CreateTask = z.object({
4 title: z.string().trim().min(1, "Title is required").max(200),
5 description: z.string().max(2000).optional(),
6 status: z.enum(["TODO", "IN_PROGRESS", "DONE"]).default("TODO"),
7 dueDate: z.coerce.date().optional(),
8});
9
10export type CreateTaskInput = z.infer<typeof CreateTask>;One schema gives you runtime validation and a static TypeScript type for free.
1const parsed = CreateTask.safeParse(await req.json());
2if (!parsed.success) {
3 return NextResponse.json(
4 { error: "Validation failed", issues: parsed.error.flatten().fieldErrors },
5 { status: 400 },
6 );
7}
8const data = parsed.data; // fully typed & sanitizedValidate once, at the edge of your system (the route handler). After that, the rest of your code can trust the data's shape. Don't re-validate in every function — validate at the door, type everything behind it.
Schemas can clean data, not just reject it:
1email: z.string().email().toLowerCase().trim(),
2page: z.coerce.number().int().min(1).default(1),
3tags: z.array(z.string()).max(10),z.coerce turns the string "2" from a query param into the number 2.
Define exactly what you accept and drop everything else. A schema with known keys naturally ignores extra fields a client tries to sneak in — your defense against mass assignment (next lesson).