React
react-hook-form + a Zod schema
zodResolver wires the schema to the form: Zod validates, react-hook-form routes errors per field, and z.infer supplies the value type.
Key points
- zodResolver is the bridge between the Zod schema and useForm.
- handleSubmit runs the resolver before calling the submit function.
- A schema error arrives already keyed to the field name.
- The resolver runs in the browser: the server must validate again.
Wire Zod to react-hook-form with zodResolver: the schema validates, the form routes errors per field, and the value type comes from z.infer without being declared twice.
Who validates what
The Zod schema holds the rules, the resolver translates them into the shape react-hook-form expects, and the form decides when to run them. None of the three duplicates the other two.
import { z } from "zod";
export const signupSchema = z.object({
email: z.email(),
password: z.string().min(12),
});
export type SignupValues = z.infer;
The form in a few lines
const form = useForm({
resolver: zodResolver(signupSchema),
defaultValues: { email: "", password: "" },
});
const onSubmit = form.handleSubmit(async (values) => {
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(values),
});
});
handleSubmit runs the resolver first: when the schema fails, the function does not start and no request leaves the browser. The JSON body reaching the server has been checked on the client, never trusted.
Errors per field
{form.formState.errors.email && (
{form.formState.errors.email.message}
)}
The message comes from the schema, the aria-invalid attribute from the same error: field and announcement stay aligned without hand-written glue code.
When to validate
- onSubmit: the default, no check while typing; the error appears on the first submit.
- onTouched: validates after the first blur, then on every change of the touched field.
- onChange: validates on every keystroke; it is the most expensive and suits fields with light rules.
The command
npm install react-hook-form zod @hookform/resolvers
All three libraries ship under the MIT licence; the resolver is the bridge, so the Zod version must sit inside the range declared by @hookform/resolvers.
Live records in the catalogue
Three records show the same pattern: multi-step-form validates one step at a time, sign-in keeps errors next to the fields, and the shadcn library supplies the form components.
The limitation
The resolver runs in the browser only: a hand-built request skips it. The same schema must run again on the server, and z.infer does not protect the default values, which stay a hand-written object that can drift from the schema.
Where to continue
The schema on its own is in validation with Zod; the shadcn version is in shadcn form with react-hook-form; the multi-step form is in multi-step form; the library comparison is in React form libraries.
Frequently asked questions
Why is a resolver needed?
Because react-hook-form does not know Zod: the resolver runs the schema and converts its errors into the shape the form can display.
Do I declare the type twice?
No. The type comes from z.infer on the schema; useForm takes it as a generic parameter and the fields stay typed.
Where does server-side validation go?
On the server, with the same schema: the browser resolver is convenience, not control. Incoming data must be read and validated again.
Can I validate on every keystroke?
Yes, with mode onChange. It costs one schema run per keystroke, so keep it for small forms or fields with simple rules.
What happens when the schema and the defaults disagree?
The form still compiles, but a missing field stays empty and the type does not flag it: defaultValues are written by hand and must be kept aligned.