Don't trust the form. Don't trust the client. Validate on the server, every time.
A motivated user can:
If the only validation is client-side, your server data is as good as untrusted.
1// lib/validators/course.ts
2import { z } from "zod";
3
4export const courseInputSchema = z.object({
5 title: z.string().min(3).max(200),
6 slug: z.string().min(3).max(80).regex(/^[a-z0-9-]+$/),
7 level: z.enum(["beginner", "intermediate", "advanced"]).default("beginner"),
8 price: z.coerce.number().int().min(0).max(99_999),
9 isFree: z.coerce.boolean().default(false),
10});
11
12export type CourseInput = z.infer<typeof courseInputSchema>;z.coerce.number() and z.coerce.boolean() are gold for FormData, where every value is a string.
1"use server";
2import { courseInputSchema } from "@/lib/validators/course";
3
4type State = { ok: boolean; errors?: Record<string, string> };
5
6export async function createCourse(prev: State, formData: FormData): Promise<State> {
7 const parsed = courseInputSchema.safeParse(Object.fromEntries(formData));
8
9 if (!parsed.success) {
10 return {
11 ok: false,
12 errors: Object.fromEntries(
13 parsed.error.issues.map((i) => [i.path[0] as string, i.message]),
14 ),
15 };
16 }
17
18 const course = await db.course.create({ data: parsed.data });
19 return { ok: true };
20}Re-use the same Zod schema in the client component to give instant feedback while typing — without duplicating rules:
1"use client";
2import { courseInputSchema } from "@/lib/validators/course";
3import { useState } from "react";
4
5export function CourseForm() {
6 const [errors, setErrors] = useState<Record<string, string>>({});
7
8 function onBlur(e: React.FocusEvent<HTMLInputElement>) {
9 const field = e.target.name;
10 const value = e.target.value;
11 const result = courseInputSchema.shape[field as keyof CourseInput].safeParse(value);
12 setErrors((s) => ({ ...s, [field]: result.success ? "" : result.error.issues[0].message }));
13 }
14 // …
15}| Layer | Catches | Implemented with |
|---|---|---|
| HTML attributes (required, minlength) | Typos | The browser |
| Client-side Zod | UX feedback | Same schema |
| Server-side Zod | Malicious / scripted requests | Same schema |
| DB constraints (UNIQUE, CHECK) | Race conditions | SQL |
Use all four. The schema is the contract; the DB is the law.
A robust action returns errors keyed by field name. The form then renders them adjacent to the right input:
1{state.errors?.title && <p className="text-red-600 text-xs">{state.errors.title}</p>}A single user-visible error toast is a fallback — good forms tell the user precisely which field is wrong.