zodResolver: one schema, both sides of the wire
Wire one Zod schema into React Hook Form with zodResolver so the same rules validate the form and the Server Action, then route server-only errors back to the right field.
The skeleton from the last lesson has two loose ends, both marked next lesson. In useForm, resolver: zodResolver(InvoiceSchema) is wired but unexplained. In onSubmit, the server’s result.error.fieldErrors never makes it back into the form. This lesson ties off both.
Your createInvoice action already validates on entry with InvoiceSchema.safeParse(...), but only after a server round-trip. The form wants those same rules client-side, to flag a malformed email the moment the user leaves the field. The tempting shortcut, a parallel client schema or per-field rules in register, works until someone tightens a check in one place and forgets the other, and the form starts accepting what the server rejects.
So one schema feeds both sides. You’ll wire the resolver, type the form when the schema transforms its values, choose which shape to call the action with, and route the failures the schema can’t predict, such as a taken email, back to the right field. Throughout, hold one idea: the resolver is a convenience for the user, and the action’s parse is still the gate.
A resolver is the form’s only validation source
Section titled “A resolver is the form’s only validation source”When you submit, React Hook Form decides whether the values are valid, and if not, which fields failed. It delegates that decision to a single function you hand it, the resolver , which takes the current field values and returns either the parsed values or a map of field errors:
type Resolver = (values: FieldValues) => { values: Output; errors: FieldErrors };Anything that produces { values, errors } can be a resolver. You could write one by hand, but validation libraries describe these rules better than imperative code, and you already have a Zod schema doing exactly that on the server.
That is what @hookform/resolvers is for: a small package, separate from react-hook-form, that ships pre-built resolvers for the common validation libraries: Zod, Valibot, ArkType, Yup, and more. Hand zodResolver a schema and you get back a resolver shaped like the contract above.
One rule shapes every decision that follows: the resolver is the form’s only validation source. No required or minLength in register, no hand-written setError for a malformed email. If a rule is about the shape of the data, such as a format, a length, or a required field, it lives in the schema, and the resolver enforces it for free.
Wire the resolver: install, import, pass
Section titled “Wire the resolver: install, import, pass”-
Install
@hookform/resolvers. It ships separately fromreact-hook-form, so it needs its own install. You already addedzodback in the Zod chapter, so it’s not on this line.Terminal window pnpm add @hookform/resolvers -
Import the Zod adapter. It lives under the package’s
/zodentry point.import { zodResolver } from '@hookform/resolvers/zod'; -
Pass it to
useForm. The line that was a comment in the last lesson is now real.app/invoices/new-invoice-form.tsx const form = useForm<InvoiceInput, unknown, Invoice>({resolver: zodResolver(InvoiceSchema),defaultValues: { customer: '', email: '', total: 0 },mode: 'onBlur',});
The resolver now runs on the mode you set, 'onBlur', and again on every handleSubmit. When it fails, the errors land in formState.errors, keyed by the schema’s field paths: customer, email, total. That is exactly where your Field rows already read from through <FieldError>, so the form lights up the moment the resolver is wired, with no per-field changes and no new JSX.
Both the form and the action import one schema
Section titled “Both the form and the action import one schema”The schema lives in one shared module the feature owns alongside its action. The action imports InvoiceSchema and parses incoming data against it; the form imports the same InvoiceSchema and hands it to zodResolver. One definition holds the field rules, error messages, and transforms, and both sides read from it.
import { z } from 'zod';
export const InvoiceSchema = z.object({ customer: z.string().min(1), email: z.email(), total: z.coerce.number<number>().positive(), // generic pins the input type — see below});
export type InvoiceInput = z.input<typeof InvoiceSchema>;export type Invoice = z.output<typeof InvoiceSchema>;The source. Field rules, error messages, and transforms all live here. InvoiceInput is the shape going in; Invoice is the shape coming out. They differ, and the next section is about why.
'use server';
import { InvoiceSchema } from './_lib/invoice-schema';
export async function createInvoice(input: Invoice) { const parsed = InvoiceSchema.safeParse(input); if (!parsed.success) { // ...return the Result with fieldErrors (Server Actions chapter) } // ...authorize, mutate, revalidate, return Result}The gate. The action parses on entry, unchanged from the Server Actions chapter.
'use client';
import { InvoiceSchema } from './_lib/invoice-schema';
const form = useForm<InvoiceInput, unknown, Invoice>({ resolver: zodResolver(InvoiceSchema), defaultValues: { customer: '', email: '', total: 0 }, mode: 'onBlur',});The renderer. Same import, client-side, fed to zodResolver, so the form validates against the exact rules the action parses with.
The payoff is that rules change once. Add a notes field, tighten customer to .min(2), or reword the email error, and the edit happens in the schema alone; the client experience and the server gate move together because neither side keeps its own copy. A parallel client schema means editing two places every time, and a missed edit fails quietly: the form keeps accepting last week’s shape while the server enforces this week’s.
That gives you a reviewer reflex: a pull request that adds a validation rule inside the form component, one not in the schema, gets sent back. New rules go in the source.
Running the schema in two runtimes isn’t wasteful, because the two runs do different jobs. The resolver gives the user fast inline feedback; the action re-parses because the client can be skipped entirely, by calling the action from devtools, replaying a captured request, or turning JavaScript off. Delete either and you lose a job the other was never doing.
Typing the form: z.input vs z.output
Section titled “Typing the form: z.input vs z.output”When a schema transforms its values, its input and output types diverge, and the form must track one while receiving the other. A form that type-checked yesterday starts lying the moment a transform appears.
A schema with a .transform(), a z.coerce, or a .default() accepts one type and produces another. z.coerce.number(), the case on total, produces a number but accepts unknown — not string, even though the value arrives from an <input> as text.
That distinction lands on the form in two places:
- React Hook Form tracks the input type: what
defaultValuesmust match, whatfield.valueis, whatregisterwires up. With a barez.coerce.number(), the input type oftotalisunknown, sodefaultValues: { total: 0 }hands anumberto a field RHF thinks holdsunknown. onSubmitreceives the output type, after the resolver runs, sototalis a cleannumber.
To fix that unknown, pin the coercion’s input with a generic, as the schema tab showed: z.coerce.number<number>() resolves the input type to number. Now defaultValues: { total: 0 } type-checks, while the output stays number.
The form straddles both types: it tracks the input and receives the output. useForm takes three type parameters for exactly this: the values it tracks, a context type you rarely use, and the transformed values the submit handler receives.
const form = useForm<InvoiceInput, unknown, Invoice>({ resolver: zodResolver(InvoiceSchema), defaultValues: { customer: '', email: '', total: 0 }, mode: 'onBlur',});
const onSubmit = async (values: Invoice) => { // values.total is a number here — the resolver ran the coercion await createInvoice(values);};The first parameter is what RHF tracks, the input type. Every defaultValues entry, field.value, and register is this raw, pre-coercion shape.
const form = useForm<InvoiceInput, unknown, Invoice>({ resolver: zodResolver(InvoiceSchema), defaultValues: { customer: '', email: '', total: 0 }, mode: 'onBlur',});
const onSubmit = async (values: Invoice) => { // values.total is a number here — the resolver ran the coercion await createInvoice(values);};The third parameter is what onSubmit receives, the output type, after the resolver transformed the values. The middle slot is the rarely-used context type, where unknown is fine.
const form = useForm<InvoiceInput, unknown, Invoice>({ resolver: zodResolver(InvoiceSchema), defaultValues: { customer: '', email: '', total: 0 }, mode: 'onBlur',});
const onSubmit = async (values: Invoice) => { // values.total is a number here — the resolver ran the coercion await createInvoice(values);};Inside onSubmit the resolver has already run, so total is a real number. You call the action with clean, output-typed values, no manual Number(...).
Read the three slots as one sentence: track this, transform, receive that.
The shortcut is a trap. useForm<z.infer<typeof InvoiceSchema>>() compiles while the schema has no transforms. Add a z.coerce or a .default() and the input and output types drift apart, but z.infer is the output, so it silently mistypes what register and defaultValues track. No error: the form just quietly disagrees with itself about what it holds. Spelling out z.input and z.output is correct from the first line.
This coerce is the same tool from the Zod chapter’s FormData lesson, where every value crossing the form boundary arrives as a string because FormData has no numbers. There the string-to-number seam sat at the network edge; here it is internal, between what RHF tracks and what the action expects.
Calling the action: typed object vs FormData
Section titled “Calling the action: typed object vs FormData”The last lesson’s skeleton called createInvoice(values) from inside onSubmit but left the shape of that call open. There’s a default, not two equal options.
Typed object: the default when React Hook Form is the only caller. RHF already holds the typed, coerced object, so onSubmit passes it straight through with await createInvoice(values); the action takes createInvoice(input: Invoice) and still opens with InvoiceSchema.safeParse(input). Packing the object into FormData only to unpack it on the other side is pure ceremony.
Keep FormData: when the endpoint serves more than this form. If the same action also answers a non-RHF caller, such as a no-JS fallback or a native <form action> like the previous chapter built, build FormData from values and call createInvoice(formData) so the action’s Object.fromEntries(formData) parse serves every caller the same way.
// in the formconst onSubmit = async (values: Invoice) => { const result = await createInvoice(values); // ...handle result};
// in the actionexport async function createInvoice(input: Invoice) { const parsed = InvoiceSchema.safeParse(input); // ...}The default. RHF already has the object; pass it straight through, and the action parses it directly. No reconstruction, no string round-trip.
// in the formconst onSubmit = async (values: Invoice) => { const formData = new FormData(); formData.set('customer', values.customer); formData.set('email', values.email); formData.set('total', String(values.total)); const result = await createInvoice(formData); // ...handle result};
// in the actionexport async function createInvoice(formData: FormData) { const parsed = InvoiceSchema.safeParse(Object.fromEntries(formData)); // ...}The multi-caller shape. You rebuild FormData by hand; note String(values.total), since FormData only holds strings. Worth it only when a native form or no-JS path hits the same action.
The rule: if React Hook Form is the only caller, pass the typed object; if the action serves other shapes too, keep FormData. The FormData tab is what that second condition costs, so reach for it only when the condition holds.
Either way the action’s first move is safeParse: the typed-object action parses the object, the FormData action parses Object.fromEntries(formData), but the input is always parsed, so the trust boundary is identical regardless of call shape. This is why the chapter’s project keeps FormData: built on the native pattern, an RHF form calling that same createInvoice is the multi-caller case in the flesh.
Mapping server errors back into the form
Section titled “Mapping server errors back into the form”This fills the skeleton’s // map result.error.fieldErrors back into the form: turning a failure the schema could never predict into a red message under the right field.
Some failures the resolver can’t cover. The user types ada@example.com, the resolver checks it against z.email() and finds it well-formed, but on submit the database reports that email is already taken. Uniqueness lives in the database, not in the shape of a string, so the action returns the course Result on its failure arm:
{ ok: false, error: { code: 'conflict', userMessage: 'That email is already in use.', fieldErrors: { email: ['That email is already in use.'] }, },}fieldErrors is a flat map from field name to an array of messages, the contract the Server Actions chapter locked in. On the client, surface each message on its field.
React Hook Form’s form.setError(name, { message }) pushes an error into formState.errors[name], the same place the resolver writes. So a server error needs no new UI: it flows through the identical Field and <FieldError> row that renders your client-side validation.
Walk the returned fieldErrors and call setError for each. Every form does this identically, so hoist it into the promised helper, applyServerErrors(form, result):
const onSubmit = async (values: Invoice) => { const result = await createInvoice(values); if (result.ok) { form.reset(values); return; } applyServerErrors(form, result);};
function applyServerErrors( form: UseFormReturn<InvoiceInput, unknown, Invoice>, result: { ok: false; error: { fieldErrors?: Record<string, string[]> } },) { const fieldErrors = result.error.fieldErrors ?? {}; for (const [name, messages] of Object.entries(fieldErrors)) { form.setError(name as FieldPath<InvoiceInput>, { message: messages[0] }); }}The action returns the canonical Result. The resolver already passed, so this round-trip catches only what the server can know: the taken email.
const onSubmit = async (values: Invoice) => { const result = await createInvoice(values); if (result.ok) { form.reset(values); return; } applyServerErrors(form, result);};
function applyServerErrors( form: UseFormReturn<InvoiceInput, unknown, Invoice>, result: { ok: false; error: { fieldErrors?: Record<string, string[]> } },) { const fieldErrors = result.error.fieldErrors ?? {}; for (const [name, messages] of Object.entries(fieldErrors)) { form.setError(name as FieldPath<InvoiceInput>, { message: messages[0] }); }}Success: reset to the saved values, clearing the dirty state so the form looks freshly loaded.
const onSubmit = async (values: Invoice) => { const result = await createInvoice(values); if (result.ok) { form.reset(values); return; } applyServerErrors(form, result);};
function applyServerErrors( form: UseFormReturn<InvoiceInput, unknown, Invoice>, result: { ok: false; error: { fieldErrors?: Record<string, string[]> } },) { const fieldErrors = result.error.fieldErrors ?? {}; for (const [name, messages] of Object.entries(fieldErrors)) { form.setError(name as FieldPath<InvoiceInput>, { message: messages[0] }); }}Failure with field errors: hand the whole thing to the helper, the one call site every form shares.
const onSubmit = async (values: Invoice) => { const result = await createInvoice(values); if (result.ok) { form.reset(values); return; } applyServerErrors(form, result);};
function applyServerErrors( form: UseFormReturn<InvoiceInput, unknown, Invoice>, result: { ok: false; error: { fieldErrors?: Record<string, string[]> } },) { const fieldErrors = result.error.fieldErrors ?? {}; for (const [name, messages] of Object.entries(fieldErrors)) { form.setError(name as FieldPath<InvoiceInput>, { message: messages[0] }); }}setError writes into formState.errors, where the resolver writes, so the existing <FieldError> row renders the server’s message with zero new UI. Read messages[0], matching the flat contract.
Two things are easy to get wrong here.
First, don’t reach for setValue('email', value, { shouldValidate: true }). setValue changes a value, and shouldValidate re-runs the resolver; a well-formed email passes, wiping out the server’s “already taken” message. setError pushes the error, setValue changes the field; they are not interchangeable.
Second, a quieter trap: setError on a name that doesn’t match a registered field is a silent no-op, no crash and no warning, so the message never appears. The schema keys, the field names, and the action’s fieldErrors keys all agree because they derive from one schema, so the loop is safe by construction.
One last knob, mode versus reValidateMode. mode sets when validation first runs for a field: the default is 'onSubmit', but the course uses 'onBlur', so a field is first checked when the user leaves it. reValidateMode sets when validation re-runs after that field has already errored: the default is 'onChange', so an errored field re-checks on every keystroke and the error clears the instant the value becomes valid. Together they give the “validate on blur, then fix as you type” feel:
const form = useForm<InvoiceInput, unknown, Invoice>({ resolver: zodResolver(InvoiceSchema), mode: 'onBlur', reValidateMode: 'onChange',});Async validation in the schema
Section titled “Async validation in the schema”.refine() also accepts an async predicate that the resolver awaits, so a schema can run a live “is this username available?” check as the user types. Prefer routing uniqueness through the action’s fieldErrors path you just built; the live check also needs a route handler, which the next chapter covers.
Check your understanding
Section titled “Check your understanding”This schema coerces total from a string to a number, so its input and output types differ. Fill the first blank with the type for what RHF tracks, and the onSubmit parameter with the type for what the handler receives. Pick the right option from each dropdown, then press Check.
const form = useForm<___, unknown, Invoice>({ resolver: zodResolver(InvoiceSchema), defaultValues: { customer: '', email: '', total: 0 },});
const onSubmit = async (values: ___) => { await createInvoice(values);};External resources
Section titled “External resources”The lesson stands on its own, but these fill in the corners.
The resolver package and its Zod adapter; the README documents the three-parameter useForm typing for transforming schemas.
The resolver option, the transformed-values generic, and setError for pushing server errors into the form.
The z.input / z.output distinction and coercion, if you want the source behind the type bridge.
The exact API behind the server-error section — how setError writes into formState.errors and where it differs from setValue.
A worked walkthrough, with a live CodeSandbox, of the reusable setError helper that maps a fieldErrors map onto the form.