Validating Input with Zod

required and type="email" on an input are a convenience for the person typing, not validation: the endpoint of Defining a Server Action never sees the form. Validation happens inside the action, on the raw FormData. Zod 44,027 (MIT, npm 2,036 install zod, version 4.6.5) is the usual tool — declare the shape once, and safeParse returns either typed data or a structured error without throwing.

Parsing FormData in a Server Action (app/notes/actions.js)JavaScript
const NoteInput = z.object({
  text: z.string().trim().min(3, "A note needs at least 3 characters.").max(80),
  due: z.iso.date("Use YYYY-MM-DD.").optional(),
});
export async function createNote(prevState, formData) {
  const parsed = NoteInput.safeParse({ text: formData.get("text"),
    due: formData.get("due") || undefined });   // "" is not an absent field
  if (!parsed.success) {
    const f = z.flattenError(parsed.error).fieldErrors;
    return { error: f.text?.[0] ?? f.due?.[0] ?? "Invalid input." };
  }
  return { error: null, saved: addNote(parsed.data.text, user.id).text };
}
Output
{ "formErrors": [], "fieldErrors": {
    "text": [ "A note needs at least 3 characters." ],
    "due":  [ "Use YYYY-MM-DD." ] } }

z.flattenError, shown above rejecting { text: "ok", due: "next friday" }, turns a nested issue tree into one array of messages per field. Two Zod 4 spellings differ from the version 3 code still all over the web: z.flattenError(error) and z.treeifyError(error) are top-level functions rather than error.flatten() and error.format(), and string formats moved to their own namespace, so z.iso.date() replaces z.string().date() and z.email() replaces z.string().email(). Returning the error rather than throwing it lets useActionState render it beside the field (Form State and Pending UI).

A FormData value is always a string or a File, so coerce deliberately: z.coerce.number() for a number, z.stringbool() for a checkbox, z.array(...) over formData.getAll("tag") for a multi-select.

Note the limit of schema validation, in the Next.js 10,514 documentation's own words: it "only checks the shape of the input. A well-formed Item object can still refer to a row the caller does not own." Zod confirms that { id: "n2", done: true } is a valid update while n2 belongs to Ada. Validation is not authorization.