Build forms that validate
Users fumble forms — malformed emails, blank required fields, 3-character passwords — and if you don't catch it, garbage lands in your database or your server crashes on bad input. A form that validates gives instant, clear feedback and refuses bad data before it's ever saved.
Treat your validation schema as the single source of truth. Define it once with Zod, infer your TypeScript types from it, and run the exact same schema in two places: on the client with React Hook Form for instant, friendly feedback, and again on the server before you touch the database. Client-side validation is a UX feature, not a security boundary — anyone can open devtools and POST whatever they want, so the server must re-check. In 2026 this "one schema, validate twice" pattern is the default; don't hand-roll `if (!email.includes('@'))` checks that drift out of sync.
Step by step
- 1
Write one schema as the source of truth
Define a Zod schema describing every field and rule (email format, min length, required), then infer your form's TypeScript type from it with z.infer. This one object drives your types, your client validation, and your server validation — nothing gets duplicated.
- 2
Wire the form with React Hook Form + zodResolver
Pass your schema to useForm via zodResolver so the form validates against it automatically. Register each input and pull errors out of formState — you get typed values and per-field error messages for free, no manual state juggling.
- 3
Render errors accessibly
Tie each label to its input with htmlFor/id, set aria-invalid when a field has an error, point aria-describedby at the error element, and give the error node role="alert" so screen readers announce it. Add noValidate to the form so your messages win over the browser's default popups.
- 4
Pick a sensible validation timing
Set React Hook Form's mode to 'onTouched' or 'onBlur' so you don't scream at users on the first keystroke, then it re-validates on change once a field has an error. Disable the submit button while isSubmitting is true to prevent double submits.
- 5
Re-validate on the server with the SAME schema
In your Server Action or API route, run schema.safeParse(body) on the incoming data before doing anything. If it fails, return the field errors; if it passes, you have clean, typed data. This is the line of defense that actually protects your database — the client check is just for feel.
- 6
Map server errors back onto the form
For failures the client can't know about (email already taken, coupon expired), return structured field errors from the server and use setError to attach them to the right input. The user sees the message next to the field, not a generic toast.
Starter snippet
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// One schema — reuse this exact object on the server too.
const schema = z.object({
email: z.email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
});
type FormValues = z.infer<typeof schema>;
export function SignupForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } =
useForm<FormValues>({ resolver: zodResolver(schema), mode: "onTouched" });
async function onSubmit(values: FormValues) {
await fetch("/api/signup", { method: "POST", body: JSON.stringify(values) });
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="email">Email</label>
<input id="email" type="email" aria-invalid={!!errors.email}
aria-describedby="email-error" {...register("email")} />
{errors.email && <p id="email-error" role="alert">{errors.email.message}</p>}
<button type="submit" disabled={isSubmitting}>Create account</button>
</form>
);
}✕ Watch out for
- Validating only on the client. JS validation is trivially bypassed — always re-parse the payload with the same schema on the server before writing to the DB.
- Keeping two sets of rules. If you hand-write client checks and separate server checks, they drift apart and one becomes wrong. Import the one Zod schema in both places.
- Skipping accessibility. Without label/id association, aria-invalid, aria-describedby, and role="alert", screen-reader users never hear the error. This is a real bug, not a nice-to-have.
- Forgetting that form inputs are strings. A number field arrives as "42", a checkbox as "on". Use z.coerce.number() / proper transforms or your server validation rejects valid input.
- Nagging on every keystroke. Default onChange validation flags the email as invalid before the user finishes typing it. Use mode 'onTouched' or 'onBlur' instead.
✓ Pro tips
- When prompting an AI agent, say it explicitly: "Define one Zod schema and validate with it on BOTH the client (React Hook Form) and the server — client validation alone is not enough." Agents love to scaffold a pretty client form and silently skip the server check. Afterward, open the API route/Server Action and confirm it actually calls schema.safeParse on the incoming body.
- Tell the agent to wire full accessibility (htmlFor/id pairing, aria-invalid, aria-describedby, role="alert") and then verify by tabbing through with the keyboard and running an axe/Lighthouse a11y pass — don't take its word for it.
- Ask for human error messages. Agents ship Zod's default "Invalid input" strings; give every rule a custom message like "Password must be at least 8 characters."
- Reach for TanStack Form or Conform if you're building server-action-first in Next.js — Conform is designed around progressive enhancement so the form still validates with JS disabled.