# Type-Safe Forms with React Hook Form and Zod

> How to wire React Hook Form to a Zod resolver, share one schema between client and server, and get clean, type-safe error handling for free.

URL: https://www.matthewswong.com/en/blog/react-hook-form-zod-type-safe-forms/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-07-09
- Updated: 2026-07-09
- Category: Web Dev

Every form eventually grows the same three problems: the client validation drifts from the server validation, the TypeScript types drift from both, and the error messages end up scattered across components instead of living next to the rules they belong to. React Hook Form paired with Zod solves all three at once, and it does it with less code than most teams expect.

This post walks through a production-shaped setup: a single Zod schema that defines validation rules and doubles as the TypeScript type, a zodResolver wired into useForm, a matching API route that re-validates the same schema on the server, and the callout patterns worth knowing before you ship a form to real users.

## Define the schema once, derive the type from it

The core idea is to stop writing a TypeScript interface and a validation function separately. Zod schemas describe both the shape of the data and the rules it must satisfy, and TypeScript can read the type straight off the schema using the built in inference utility. Change a rule in the schema and the type updates automatically, so the form component, the submit handler, and the API route all stay in sync without a second edit.

> Keep schema files framework agnostic. A schema in schemas slash contact form dot ts should not import anything from React Hook Form or Next.js, so the exact same file can run in a browser form and in an API route without any adapter code.

## Wire the resolver into useForm

The hookform resolvers package ships a zodResolver function that adapts a Zod schema into the resolver shape React Hook Form expects. Passing the schema through zodResolver into useForm means every field registered with the register function is validated against the same rules, and the errors object in formState is populated with the exact messages defined in the schema, with no separate validation logic in the component.

Notice that the component never manually calls parse or safeParse. React Hook Form runs the resolver internally on every validation trigger, whether that is onBlur, onChange, or on submit, based on the mode you configure. The component only reads the resulting errors object and the isSubmitting flag, which keeps the JSX focused on layout rather than validation plumbing.

## Re-validate the same schema on the server

Client-side validation is a user-experience feature, not a security boundary. A request can always reach an API route without going through the form, whether from a browser extension, a replayed request, or a completely different client, so the same rules need to run again on the server. Importing the identical schema into the route handler and calling safeParse gives you three things for the cost of one import:

- The server rejects malformed payloads with the exact same field-level messages the client already shows, so error handling code is not duplicated in two different shapes.
- Because the schema lives in a shared module, a rule change is guaranteed to apply to both sides the next time the code is deployed, closing the classic gap where client and server validation quietly diverge over time.
- result dot data from a successful safeParse call is already typed as the inferred schema type, so the rest of the handler works with fully typed, already-validated data instead of an untyped request body.

> Do not skip server-side validation because the client already checks the fields. Client bundles can be inspected and modified in the browser, and any endpoint reachable over the network should assume the request body has not been validated yet.

## Choosing a validation trigger mode

useForm accepts a mode option that controls when the resolver actually runs, and picking the wrong one is a common source of forms that feel either sluggish or naggy. The table below covers the three modes used in most production forms.

Mode

When it validates

Trade-off

onSubmit

Only when the form is submitted

Feels quiet while typing, but the user only discovers mistakes after clicking submit.

onBlur

When a field loses focus, then live after the first error

The best default for most forms — feedback arrives right after the user finishes a field.

onChange

On every keystroke

Immediate feedback, but can feel noisy on longer fields like passwords or free text.

## Cross-field rules with refine

Some validation rules cannot be expressed on a single field, the classic example being a confirm-password field that must match the original password. Zod's refine method runs after the individual field rules pass and lets you compare multiple fields at once, attaching the resulting error to a specific field path so React Hook Form displays it in the right place.

The path option in the refine call is what makes this useful in a form. Without it, the error would show up as a form-level error with no obvious field to attach it to. With path set to the confirm password field, React Hook Form's errors object exposes it exactly like any other field error, so the JSX does not need any special case to render it.

> Because refine runs as part of the same schema, this cross-field rule is enforced identically on the client and the server, with zero extra code on either side.

## A short checklist before shipping a form

A handful of habits separate a form that looks type-safe from one that actually is. Run through these before merging:

- Confirm the component never redeclares a type that could instead be inferred from the schema — duplicated types are the first thing to drift.
- Confirm the API route imports the same schema file as the client component, not a copy or a hand-written duplicate.
- Confirm every array field and optional field has an explicit default value in useForm's defaultValues, since Zod optional fields can otherwise leave inputs uncontrolled.
- Confirm error messages are written for the end user, not for a developer — a message should say what to fix, not just that a rule failed.

> Split large forms into multiple smaller Zod schemas and compose them with merge or extend rather than writing one enormous object schema. It keeps each section testable on its own and makes multi-step wizards straightforward to build later.

## Testing the schema instead of testing the form

Because the validation rules live in a plain schema file with no dependency on React or the DOM, they can be unit tested directly without rendering a single component. A test file that imports the schema and calls safeParse with a handful of valid and invalid payloads catches rule regressions far faster than a browser-based test that has to render the form, fill in fields, and inspect rendered error text.

This also means schema tests double as living documentation for every rule in the form. A reviewer who wants to know exactly what makes an email field invalid does not need to trace through component code — the answer is sitting in the schema test file, expressed as concrete example payloads that either pass or fail.

The pattern in this post scales from a two-field contact form to a multi-step checkout flow without changing shape: one schema, one inferred type, one resolver, and one server-side safeParse call that mirrors the client. Once that shared schema exists, adding a field means editing exactly one file instead of three.

## Sources

- Zod — TypeScript-first schema validation
- Zod — schema definitions and refine or superRefine API
- react-hook-form or resolvers — zodResolver on GitHub

> **Key takeaway:** React Hook Form paired with a Zod schema lets one file define validation rules, infer the TypeScript type automatically, and re-validate the identical rules in an API route with safeParse, so client validation, server validation, and types never drift apart. Cross-field checks use Zod's refine method, and the schema can be unit tested without rendering the form.

## Source links

- https://zod.dev/
- https://zod.dev/api
- https://github.com/react-hook-form/resolvers

## FAQ

### What is the zodResolver used for in React Hook Form?

zodResolver adapts a Zod schema so React Hook Form can use it as a validation resolver. It is imported from the hookform resolvers package and passed into the resolver option of useForm, so every registered field is validated against the schema's rules and the resulting messages populate formState errors automatically.

### Why should I share the same Zod schema between the client and the server?

Sharing one schema means the validation rules and the TypeScript types only exist in one place, so a rule change automatically applies everywhere the schema is imported. It also closes the common gap where client-side checks and server-side checks quietly drift apart after repeated edits, since both sides run the exact same code.

### Can I get the TypeScript type directly from a Zod schema instead of writing it separately?

Yes. Zod's inference utility reads the shape and rules of a schema and produces the matching TypeScript type automatically. This means a form's props, its useForm generic, and its API route payload type can all reference the same inferred type instead of three separately maintained interfaces.

### How do I validate a field against another field, like a confirm password input?

Use Zod's refine method after the object schema is defined. It runs once every individual field passes its own rules, lets you compare two or more fields, and accepts a path option that tells React Hook Form which specific field should display the resulting error message.

### Is client-side validation with React Hook Form and Zod enough on its own?

No. Client-side validation only improves the user experience; it does not protect an API route from a request that bypasses the form entirely. The same Zod schema should be imported into the server route and run through safeParse so malformed data is rejected regardless of where the request came from.

---

- HTML version: https://www.matthewswong.com/en/blog/react-hook-form-zod-type-safe-forms/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/react-hook-form-zod-type-safe-forms/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
