Step 4
Input validation + length caps
25 min
Input validation + length caps
Every value from the client is suspect. Revalidate on the server with zod or Valibot.
1. Why re-validate?
- Browser checks are bypassable (DevTools, curl)
- Frontend version drift can skip checks
- Malicious clients don't use your UI
Frontend validation is UX; server validation is the boundary.
2. zod minimal
pnpm add zod
import { z } from "zod";
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1).max(10000),
tags: z.array(z.string().max(30)).max(10).optional(),
published: z.boolean().default(false),
});
const parsed = CreatePostSchema.safeParse(await req.json());
if (!parsed.success) return NextResponse.json(
{ error: "invalid", issues: parsed.error.flatten() }, { status: 400 }
);
3. Why length caps
- DoS — megabyte strings blow memory
- Storage cost — single user writes GBs
- Index perf — long text indexes get slow
Cap every string.
4. Common schemas
export const EmailSchema = z.string().email().max(120).toLowerCase();
export const SlugSchema = z.string().regex(/^[a-z0-9-]+$/).min(1).max(80);
5. Nested / unions
const NotificationSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("comment"), postId: z.number().int().positive(), commentId: z.number().int().positive() }),
z.object({ type: z.literal("like"), postId: z.number().int().positive() }),
z.object({ type: z.literal("follow"), userId: z.string().uuid() }),
]);
6. transform + pipe
const TrimmedSchema = z.string().transform(s => s.trim()).pipe(z.string().min(1));
const PhoneSchema = z.string()
.transform(s => s.replace(/[\s-]/g, ""))
.pipe(z.string().regex(/^010\d{8}$/));
7. Error shape
return NextResponse.json({
error: "validation_failed",
issues: parsed.error.issues.map(i => ({
path: i.path.join("."), message: i.message, code: i.code,
})),
}, { status: 400 });
8. Query strings
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).max(10000).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
q: z.string().max(100).optional(),
});
const params = QuerySchema.parse(Object.fromEntries(url.searchParams));
9. File upload
const MAX_SIZE = 10 * 1024 * 1024;
const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);
if (file.size > MAX_SIZE) return NextResponse.json({ error: "too_large" }, { status: 413 });
if (!ALLOWED_MIME.has(file.type)) return NextResponse.json({ error: "mime" }, { status: 415 });
// Also verify magic bytes — `file.type` can lie
10. Gotchas
- Missing
maxon string fields - No
coercewhen parsing query - Error messages leak internal info
- Using raw input instead of parsed output
Closing
A single z.string().min(1).max(N) helps against XSS, DoS, and memory attacks at once. Build schema files from day one.
Next
- 05-rate-limit-cors