Skip to content
Chapter 45Lesson 3

zodResolver: one schema, both sides of the wire

Wire one Zod schema into React Hook Form through zodResolver so the same validation rules guard the client form and the Server Action, then route server-only errors back to the right field.

The skeleton you built in the last lesson has two loose ends. The first sits in the useForm call: resolver: zodResolver(InvoiceSchema) with a // wired next lesson comment hanging off it. The second sits in onSubmit: // map result.error.fieldErrors back into the form — next lesson. This lesson ties off both.

Here is the problem they solve. Your createInvoice action already validates on entry, since its first line is InvoiceSchema.safeParse(...). But the action only runs after the user submits, and only reports back after a round-trip to the server. The form wants those same rules client-side, so it can flag a malformed email the moment the user leaves the field, with no submit and no round-trip. The tempting shortcut is to write the rules a second time: a parallel client schema, or per-field rules wired into register. That works right up until someone adds a field or tightens a length check in one place and forgets the other. Then the form accepts what the server rejects, and your users hit an error the inline validation never warned them about.

By the end of this lesson, one schema feeds both sides. You’ll wire the resolver, learn how to type the form when the schema transforms its values, decide which of two shapes to call the action with, and route the failures the schema can’t predict, such as a taken email, back to the right field. Keep one idea in view the whole way through: the resolver is a convenience for the user, and the action’s parse is still the gate.

A resolver is the form’s validation source

Section titled “A resolver is the form’s validation source”

Before wiring zodResolver, it helps to know what slot it plugs into, so it doesn’t read as magic.

React Hook Form has one job when you submit: decide whether the values are valid, and if not, which fields failed. It delegates that decision to a single function you hand it, the resolver . The resolver takes the current field values and hands back either the parsed values or a map of field errors:

type Resolver = (values: FieldValues) => { values: Output; errors: FieldErrors };

That is the whole contract. Anything that can produce { values, errors } from the form’s values can be a resolver. You could write one by hand, but you won’t, because validation libraries describe these rules better than imperative code does, and you already have a Zod schema doing exactly that on the server.

That is what @hookform/resolvers is for. It’s a small package, separate from react-hook-form, that ships pre-built resolvers for the validation libraries people actually use: Zod, Valibot, ArkType, Yup, and more. zodResolver(schema) is the adapter. Hand it a Zod schema and you get back a resolver function shaped exactly like the contract above. When validation passes, it returns the parsed values; when it fails, it returns the field errors Zod produced.

One rule shapes every decision that follows: the resolver is the form’s only validation source. That means no required or minLength rules tucked into register, and 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. The form doesn’t get a second opinion.

Because the resolver just points validation at a schema, wiring it takes three lines.

  1. Install @hookform/resolvers. It ships separately from react-hook-form, so it’s its own install. You already added zod back in the Zod chapter, so it’s not on this line.

    Terminal window
    pnpm add @hookform/resolvers
  2. Import the Zod adapter. It lives under the package’s /zod entry point.

    import { zodResolver } from '@hookform/resolvers/zod';
  3. 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',
    });

That’s the mechanical part, and it’s deliberately the shortest section in the lesson. From here the resolver runs on the mode you set, 'onBlur', which is the default the last lesson landed on, 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 the exact place your Field rows already read from through <FieldError>. So the moment the resolver is wired, the form you built last lesson lights up with validation, with no per-field changes and no new JSX. The plumbing was already there; you just turned on the water.

The rest of the lesson covers the three things that actually take thought.

This is the thesis in the title, and it’s worth making concrete enough to review in a pull request.

The schema lives in one shared module, a file the feature owns, alongside the action it serves. Two files import it. The action file imports InvoiceSchema and parses the incoming data against it. The form file imports the same InvoiceSchema and hands it to zodResolver. One definition holds the field rules, the error messages, and the value transforms, and both sides read from it.

Watch the same import appear in three places:

app/invoices/_lib/invoice-schema.ts
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 that matters.

The same import { InvoiceSchema } line appears in the action and the form, character for character. That is the entire point: not that the form and the server happen to agree, but that there is one definition, and two files point at it.

Now consider the payoff. Suppose you add a notes field, tighten customer to .min(2), or reword the email error message. Each change happens once, in the schema, and both the client experience and the server gate move together, because neither side has its own copy to forget. Compare that to the shortcut of a parallel client schema or register rules. There you edit two places every time, and when you miss one the bug is quiet. The form keeps accepting last week’s shape while the server enforces this week’s, and the mismatch only surfaces as a confused user staring at a server error their inline validation never warned them about.

That gives you a reviewer reflex worth keeping: a pull request that adds validation inside the form component, a rule that isn’t in the schema, gets sent back. The schema is the source, the form renders it, and the action enforces it. New rules go in the source.

This is also the place to be precise about why running the schema in two runtimes isn’t wasteful duplication. The resolver runs the schema client-side for fast inline feedback, which is for the user. The action runs the same schema server-side because the client can be skipped entirely: someone opens devtools and calls the action directly, replays a captured request, or turns JavaScript off so the resolver never loads. The network between the browser and your server can’t be trusted, so the server can’t assume the client validated. Same rules, two runtimes, two different jobs. The resolver makes the form pleasant, and the safeParse keeps the data clean. Deleting either one removes a job the other was never doing.

This is the section most likely to bite you. It stays invisible until a transform shows up in the schema, at which point a form that type-checked yesterday starts lying. So it earns the lesson’s most careful walkthrough.

A quick re-anchor from the Zod chapter, since that chapter owns this idea. A schema with a .transform(), a z.coerce, or a .default() has a different input type than output type: the type it accepts is not the type it produces. z.coerce.number() is the case you have on total. It produces a number, but what it accepts is unknown. Not string, even though the value arrives from an <input> as text, and not number either. It accepts unknown.

That distinction lands directly on the form, in two places:

  • The values React Hook Form tracks, which is what defaultValues must match, what field.value is, and what register wires up, are the input type: the raw, pre-transform shape. With a bare z.coerce.number(), the input type of total is unknown, which makes defaultValues: { total: 0 } and a typed number field awkward, because you’re handing a number to a field RHF thinks holds unknown.
  • The values handed to your onSubmit after the resolver runs are the output type: coerced and transformed, so total is now a clean number.

There’s a small fix for that awkward unknown, and you already saw it in the schema tab. Zod 4 lets you pin the coercion’s input type with a generic: z.coerce.number<number>() makes the input type resolve to number instead of unknown. Now defaultValues: { total: 0 } type-checks against a real number, and the registered number field has a sensible type, while the output stays number exactly as before. It’s a precision tweak, not a new concept: for an RHF number field, the pinned generic is simply the cleaner shape.

So the schema produces two genuinely different types, and the form straddles both: it tracks the input and receives the output. The right move is to tell useForm about both, so registration is typed against the input and onSubmit is typed against the output. React Hook Form’s useForm takes three type parameters for exactly this: the values it tracks, a context type you rarely use, and the transformed values your 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, every field.value, and every register is this raw, pre-coercion shape. total here is whatever goes in.

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, not the string it started as. You call the action with clean, output-typed values, with no manual Number(...) anywhere.

1 / 1

Read those three slots as one sentence: track this, transform, receive that. That’s the mental model to keep.

Now the trap, since this is a problem that looks fine until it doesn’t. Typing the form with a single inferred type, useForm<z.infer<typeof InvoiceSchema>>(), compiles while the schema has no transforms, and everything looks correct. Then someone adds a z.coerce or a .default(), the input and output types drift apart, and z.infer (which is the output) silently mistypes what register and defaultValues track. There’s no error. The form just quietly disagrees with itself about what shape it holds. Spelling out z.input and z.output explicitly is correct from the first line, before any transform exists to break the shortcut. That’s why the last lesson’s useForm<InvoiceInput> gets upgraded here to the full three-parameter form the moment the resolver lands.

It’s worth seeing where this coerce came from in the first place. Back in the Zod chapter’s FormData lesson, every value crossing the form boundary arrives as a string, because FormData has no numbers. z.coerce.number() was the tool that absorbed that string-to-number seam. Here it’s the same tool doing the same job, except now the seam is internal: it sits between what RHF tracks (the input) and what the action expects (the output). The coercion is why the two types differ at all.

If you want to watch the two types resolve for yourself, the playground below has the schema wired with both queries. Skip it if the model above already landed.

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 open with what shape. Here’s the decision, and it has a default rather than two equal options.

Typed object: the default when React Hook Form is the only caller. RHF already holds the typed, coerced object, so onSubmit just hands it over with await createInvoice(values). The action’s signature is createInvoice(input: Invoice), and its first line is still InvoiceSchema.safeParse(input), with no FormData and no Object.fromEntries. When you already have the object, packing it into FormData only to unpack it on the other side is pure ceremony. For an RHF-managed form, reach for the typed object.

Keep FormData: when the endpoint serves more than this form. Sometimes the same action also answers a non-RHF caller, such as a no-JavaScript fallback, or a native <form action> of the kind the previous chapter built, pointing at the same endpoint. To stay compatible with both, build FormData from values and call createInvoice(formData). The action keeps its Object.fromEntries(formData) parse and serves every caller the same way.

Side by side, the cost of each is visible:

// in the form
const onSubmit = async (values: Invoice) => {
const result = await createInvoice(values);
// ...handle result
};
// in the action
export async function createInvoice(input: Invoice) {
const parsed = InvoiceSchema.safeParse(input);
// ...
}

The default. RHF already has the object; pass it straight through. The action parses the object instead of a FormData. No reconstruction, no string round-trip.

The decision in one line: if React Hook Form is the only caller, pass the typed object; if the action serves other shapes too, keep FormData. Default to the typed object. The FormData tab is what the second condition costs, and you reach for it only when that condition is actually true.

Either way, notice what doesn’t change: the action’s first move is always safeParse. The typed-object action parses the object; the FormData action parses Object.fromEntries(formData). The input arrives and the action parses it, no exceptions, so the trust boundary is identical regardless of the call shape. This is also why the chapter’s project keeps FormData: it’s deliberately built on the native pattern, so an RHF form calling that same createInvoice is the multi-caller case in the flesh, not a hypothetical.

Time to tie off the second loose end: // map result.error.fieldErrors back into the form. This is where a failure the schema couldn’t have predicted becomes a red message under the right field.

Picture the case the resolver can’t cover. The user types ada@example.com and leaves the field, the resolver checks it against z.email(), and it’s well-formed, so there’s no error. They submit. The action runs, tries to insert, and the database says that email is already taken. The schema was never going to catch this, because 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 exact contract the Server Actions chapter locked in. Your job on the client is to take that map and surface each message on its field.

Here’s the mechanism, and it’s the punchline of the whole lesson. React Hook Form’s form.setError(name, { message }) pushes an error into formState.errors[name], the same place the resolver writes. So a server-pushed error doesn’t need any new UI. It flows through the identical Field and <FieldError> row that’s been rendering your client-side validation all along. Client format errors and server business errors share one rendering path, because they land in one place.

The pattern is a loop: walk the returned fieldErrors and call setError for each. Since every form will do this the same way, hoist it into the small helper the last lesson promised, applyServerErrors(form, result). Here’s the completed onSubmit with that helper, which finally erases the skeleton’s last comment:

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 ran and returned the canonical Result. The resolver already passed client-side, so this round-trip is here to catch what only 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 path: reset to the values you just saved. That clears the dirty state so the form looks freshly loaded, reusing the move from the previous lesson.

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. There’s one call site, and every form does it identically.

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, the same place the resolver writes, so the existing <FieldError> row renders the server’s message with zero new UI. Read messages[0], the first message, matching the flat contract.

1 / 1

Two things to get right, both easy to get wrong:

First, don’t reach for setValue('email', value, { shouldValidate: true }) to push a server error in. setValue changes a value, and shouldValidate re-runs the resolver against it, which for a well-formed email passes and wipes out the server’s specific “already taken” message. setError is the tool for a server-pushed error; setValue is the tool for changing what’s in the field. They are not interchangeable here.

Second, a quieter trap: setError on a name that doesn’t match a registered field is a silent no-op. There’s no crash and no warning; the message simply never appears. The defense is the thesis paying off one more time. The schema keys, the form field names, and the action’s fieldErrors keys all agree, because they all derive from the one schema. The loop you’d otherwise have to babysit is safe by construction.

One last knob to name, since the last lesson forward-referenced it: mode versus reValidateMode. They sound alike but do different things. mode sets when validation first runs for a field. Its 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. Its default is 'onChange', so once a field is showing an error, it re-checks on every keystroke and the error clears the instant the value becomes valid. Together they give the canonical “validate on blur, then fix as you type” feel:

const form = useForm<InvoiceInput, unknown, Invoice>({
resolver: zodResolver(InvoiceSchema),
mode: 'onBlur',
reValidateMode: 'onChange',
});

Async checks in the schema (the rare case)

Section titled “Async checks in the schema (the rare case)”

One option is worth knowing about, even though you won’t use it here. Zod’s .refine() accepts an async predicate, and the resolver supports it, so you can run a live “is this username available?” check as part of validation, debounced as the user types. When you do, the clean shape is to have the async refinement call a route handler that runs the uniqueness check server-side and reuses the same schema, so even the live check stays single-source. But most forms don’t need it. They route uniqueness through the action’s failure path, the fieldErrors round-trip you just built, and pay nothing for a live check the form doesn’t require. The route-handler half of this belongs to the next chapter; for now, just know the option exists.

A teammate wires zodResolver(InvoiceSchema) into the form, sees inline validation working, and opens a pull request that deletes the InvoiceSchema.safeParse(input) line from the createInvoice action — “the form already validates against this schema, so parsing again on the server is redundant.” What’s the problem?

The schema never actually reaches the server — zodResolver runs only in the browser, and a caller can route straight past it (devtools, a replayed request, JS turned off). The action’s parse is the one check that can’t be skipped, so it’s the real gate, not a duplicate of the form’s work.
Nothing’s wrong — it’s the same schema either way, so the second parse is genuinely dead code.
It’s only safe to delete if the form uses mode: 'onChange' so validation runs continuously.
The fix is the other direction: drop the resolver and validate only on the server, so there’s exactly one place.

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);
};

The lesson stands on its own, but these fill in the corners.