Forms are where most SaaS products either feel solid or feel broken. The team that ships fast, validates consistently on client and server, and degrades gracefully when the network flakes wins trust users rarely articulate but always remember. The team that doesn't ends up with a dozen bespoke validation helpers, a trail of TypeScript anys, and a backend that silently accepts payloads the UI rejected. React Hook Form plus Zod, wired correctly, solves the whole category — not because the libraries are magic, but because they force you into a pattern that scales. Here's how it looks when shipped to production.
Why schema-first, and why these two libraries
The alternative to schema-first validation is rules scattered across components, server handlers, and the occasional utility function. It works for three forms and breaks at thirty. A Zod schema is a single value that describes the shape of valid data — its TypeScript type, its runtime validator, and its error messages — all in one place. React Hook Form keeps the form state out of React's render path, so forty-field forms don't slow to a crawl on every keystroke. The two libraries meet at `@hookform/resolvers/zod`, which is a thirty-line adapter and the reason this pairing has become the default on new projects in 2026.
- Zod schemas double as TypeScript types via `z.infer<typeof schema>` — one source of truth, no manual interface drift.
- React Hook Form uses uncontrolled inputs and refs, which means re-renders stay local. A 300-field form that validates on blur feels identical to a 10-field one.
- The resolver returns errors in React Hook Form's shape, so `formState.errors` and `<ErrorMessage />` Just Work without extra mapping.
- Because Zod runs anywhere JavaScript runs, the same schema validates on the client, inside a Next.js server action, and at the edge of a Node API route.
The minimum viable production form
Before reaching for anything clever, most forms look like this: a schema, a typed `useForm`, a resolver, and a handler that submits. The snippet below is the exact skeleton we drop into new projects — no abstractions, no custom hook, no wrapper component. Once three or four forms in a project look like this, patterns emerge naturally and you can extract them.
// schemas/signup.ts — shared between client and server
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().trim().toLowerCase().email("Enter a valid email"),
password: z
.string()
.min(12, "Use at least 12 characters")
.regex(/[A-Z]/, "Include an uppercase letter")
.regex(/[0-9]/, "Include a number"),
company: z.string().trim().min(2, "Company name is required"),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: "You must accept the terms" }),
}),
});
export type SignupInput = z.infer<typeof signupSchema>;
// components/SignupForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, type SignupInput } from "@/schemas/signup";
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupInput>({
resolver: zodResolver(signupSchema),
mode: "onBlur",
defaultValues: { email: "", password: "", company: "", acceptTerms: false },
});
const onSubmit = async (data: SignupInput) => {
const res = await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json();
// surface server-side field errors back into the form
// (see the next section on async/server errors)
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="email">Work email</label>
<input
id="email"
type="email"
autoComplete="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
{/* repeat for password, company, acceptTerms */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating account..." : "Create account"}
</button>
</form>
);
}Put schemas in their own folder (e.g. `/schemas` or `/lib/validation`) and never import React or browser APIs from them. That single rule guarantees the schema stays usable from server actions, edge functions, tests, and CLI scripts.
Sharing schemas between client and server
Client-side validation is a UX feature. Server-side validation is a security feature. Teams that skip the second one end up patching bugs for months when someone discovers the API accepts a 50MB string for a field the UI capped at 100 characters. The whole point of picking Zod is that one schema satisfies both — no translation, no drift.
Next.js server action
// app/actions/signup.ts
"use server";
import { signupSchema } from "@/schemas/signup";
import { db } from "@/lib/db";
export async function signupAction(formData: FormData) {
const parsed = signupSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
company: formData.get("company"),
acceptTerms: formData.get("acceptTerms") === "on",
});
if (!parsed.success) {
return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
}
const existing = await db.user.findUnique({
where: { email: parsed.data.email },
});
if (existing) {
return { ok: false, fieldErrors: { email: ["Email already registered"] } };
}
await db.user.create({ data: parsed.data });
return { ok: true };
}The response shape matters. Returning `fieldErrors` keyed by field name means the client can splice server errors into React Hook Form using `setError(fieldName, { message })` and the existing error UI renders without any extra code. That's the whole loop: one schema, two call sites, one error surface.
Async validation — uniqueness checks done right
Username, email, slug, and subdomain fields all need to check the server while the user types. Done naively, this means a database query on every keystroke — expensive, racy, and occasionally wrong when responses arrive out of order. The pattern that holds up in production is: debounce the check, validate in an async Zod refine, and let React Hook Form's own `isValidating` state drive the UI.
import { z } from "zod";
const checkSlugAvailable = async (slug: string): Promise<boolean> => {
const res = await fetch(`/api/slugs/check?slug=${encodeURIComponent(slug)}`);
const body = (await res.json()) as { available: boolean };
return body.available;
};
export const workspaceSchema = z.object({
name: z.string().min(2),
slug: z
.string()
.min(3)
.regex(/^[a-z0-9-]+$/, "Lowercase letters, numbers, and dashes only")
.refine(checkSlugAvailable, { message: "That slug is taken" }),
});Two things to know. First, async refinements only run after synchronous checks pass — the expensive request never fires if the format is invalid. Second, with `mode: "onBlur"` or `"onTouched"`, the check runs when the user leaves the field, not on every keystroke. If you want the spinner-while-typing feel, combine `mode: "onChange"` with a debounced wrapper around `checkSlugAvailable` (200–300ms is the sweet spot).
Never trust the async check alone. A user can race the debounce window or skip client validation entirely. Re-validate the full schema inside the server action before writing — the point of sharing schemas is that this is a one-line call, not a parallel implementation.
Dynamic fields with useFieldArray
Invoice line items, team invitations, pricing tiers, form builders — any feature where users add and remove rows. React Hook Form's `useFieldArray` owns the array state, and Zod validates each row plus the array as a whole.
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const inviteSchema = z.object({
invites: z
.array(
z.object({
email: z.string().email("Enter a valid email"),
role: z.enum(["admin", "member", "viewer"]),
})
)
.min(1, "Invite at least one teammate")
.max(50, "Invite up to 50 people at a time")
.refine(
(rows) => new Set(rows.map((r) => r.email)).size === rows.length,
{ message: "Emails must be unique" }
),
});
type InviteInput = z.infer<typeof inviteSchema>;
export function InviteForm() {
const { control, register, handleSubmit, formState: { errors } } =
useForm<InviteInput>({
resolver: zodResolver(inviteSchema),
defaultValues: { invites: [{ email: "", role: "member" }] },
});
const { fields, append, remove } = useFieldArray({ control, name: "invites" });
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
{fields.map((field, index) => (
<div key={field.id}>
<input
{...register(`invites.${index}.email`)}
aria-invalid={!!errors.invites?.[index]?.email}
/>
<select {...register(`invites.${index}.role`)}>
<option value="admin">Admin</option>
<option value="member">Member</option>
<option value="viewer">Viewer</option>
</select>
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ email: "", role: "member" })}>
Add teammate
</button>
{errors.invites?.root && <p role="alert">{errors.invites.root.message}</p>}
<button type="submit">Send invites</button>
</form>
);
}Common patterns at a glance
| Requirement | Zod pattern | RHF touch point |
|---|---|---|
| Cross-field rule (password === confirm) | `z.object({...}).refine((d) => d.password === d.confirm, { path: ["confirm"] })` | Error lands on the `confirm` field automatically |
| Optional but trimmed | `z.string().trim().optional()` | `defaultValues` should use empty string, not `undefined` |
| Numeric input from HTML | `z.coerce.number().int().positive()` | No parseInt dance in the handler |
| File upload size/type | `z.instanceof(File).refine((f) => f.size < 5_000_000)` | Register with `{ valueAsFile: true }` or handle via `setValue` |
| Dependent field (state required only for US) | `z.discriminatedUnion("country", [...])` | Reset the dependent field with `watch` + `useEffect` |
| Conditional disclosure | `z.union([baseSchema, baseSchema.extend({ ... })])` | Render extra fields when `watch('type') === 'x'` |
| Server-returned errors | Return `{ fieldErrors: Record<string, string[]> }` | Loop and call `setError` per field |
Accessibility — the part that usually gets skipped
A form that validates correctly and still fails an accessibility audit is a form that has to be rewritten twice. The rules are boring and easy to apply once you know them, and they turn into muscle memory after the first two forms.
- Every input needs a real `<label htmlFor>` or `aria-label`. Placeholder-as-label fails WCAG and looks broken when autofill kicks in.
- Set `aria-invalid` based on `errors[field]` and `aria-describedby` pointing at the error element's `id`. Screen readers read the error without the user hunting.
- Give the error element `role="alert"` so it announces on change. Don't hide errors behind `display: none` — use `hidden` or conditional rendering.
- Use `autoComplete` values that match the field (`email`, `current-password`, `new-password`, `one-time-code`). Password managers and browser autofill lean on them heavily.
- Disable the submit button with `isSubmitting`, not `!isValid`. Blocking submission until every field is green prevents users from ever seeing error messages for fields they skipped.
- Add `noValidate` to the `<form>` so the browser's native bubbles don't fight your Zod messages.
Performance notes for large forms
React Hook Form is fast by default. It gets slow when you reach into `formState` in the parent component and re-render the whole form on every keystroke. Two rules keep things quick even on 100-field forms.
- Use `Controller` only for components that truly can't be registered directly (most custom date pickers, MUI selects, shadcn `<Select>`). Everything else should use `register` and stay uncontrolled.
- Scope subscriptions with `useFormState({ control, name: "fieldName" })` or `useWatch` when you need to react to specific fields. Never read `formState.errors` at the top of a big form unless the whole form needs to re-render.
- For forms larger than about 200 fields, avoid reading resolver-derived state inside the render tree. Community reports document measurable freezes during registration when the resolver runs alongside broad `formState` subscriptions.
React Hook Form v8 entered beta in early 2026 with breaking changes around formState subscriptions and resolver signatures. Audit before upgrading on a live project — pin to v7 until you have time to test the migration, and check `@hookform/resolvers` version compatibility.
Key takeaways
- One Zod schema per form. Use it on the client for UX, in server actions for security, and as the inferred TypeScript type everywhere else.
- Wire server errors back into React Hook Form with `setError` using a `fieldErrors` shape returned from the API. Users see validation in the same place whether it came from the client or server.
- Async validation belongs in Zod's `refine`, not in `onBlur` handlers. Debounce the network call, re-validate on the server, and never trust the client alone.
- Accessibility is four attributes — `aria-invalid`, `aria-describedby`, `role="alert"`, and a real `<label>`. Apply them on the first form and they stop being a thing you remember.
- Large forms stay fast when you keep subscriptions scoped. Don't read `formState.errors` from the parent of a 100-field form — scope with `useFormState` or push errors into leaf components.