React7 min read
React Hook Form + Zod: Type-Safe Form Validation Guide
Learn React Hook Form with Zod 4 for type-safe form validation: zodResolver setup, inferred types, cross-field rules, field arrays and server errors.
To validate a React form with Zod, define a Zod schema, pass zodResolver(schema) to React Hook Form's useForm, and let TypeScript infer the form types from the schema. You get one source of truth for validation rules, error messages and types, and you can reuse the same schema on the server. This guide uses React Hook Form 7, Zod 4 and @hookform/resolvers, the current versions as of late 2026.
Key takeaways
- One Zod schema gives you runtime validation and TypeScript types, so they cannot drift apart.
zodResolverfrom@hookform/resolvers/zodworks with both Zod 3 and Zod 4 schemas.- In Zod 4, use the
errorparam for custom messages and top-level formats likez.email(). The oldmessageparam still works but is deprecated. - Use
.refine()with apathfor cross-field rules like "passwords must match". - HTML inputs return strings. Handle numbers with
valueAsNumberorz.coerce. - Always validate again on the server with the same schema.
Why React Hook Form and Zod work well together
React Hook Form keeps inputs uncontrolled by default and tracks values with refs, so typing does not re-render the whole form. Zod describes the shape and rules of your data and infers TypeScript types from that description. The resolver connects the two: React Hook Form collects values, Zod validates them, and the errors come back in formState.errors.
The real win is not fewer lines. It is that your form type, your validation rules and your API payload are all derived from one schema.
Installation
npm install react-hook-form zod @hookform/resolversA basic type-safe form with zodResolver
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const contactSchema = z.object({
name: z.string().trim().min(2, { error: "Name must be at least 2 characters" }),
email: z.email({ error: "Enter a valid email address" }),
message: z
.string()
.min(10, { error: "Tell me a bit more (10+ characters)" })
.max(1000, { error: "Keep it under 1000 characters" }),
});
type ContactForm = z.infer<typeof contactSchema>;
export function ContactForm() {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(contactSchema),
defaultValues: { name: "", email: "", message: "" },
mode: "onTouched",
});
async function onSubmit(values: ContactForm) {
await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
reset();
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="name">Name</label>
<input id="name" {...register("name")} aria-invalid={!!errors.name} />
{errors.name && <p role="alert">{errors.name.message}</p>}
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} aria-invalid={!!errors.email} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<label htmlFor="message">Message</label>
<textarea id="message" {...register("message")} aria-invalid={!!errors.message} />
{errors.message && <p role="alert">{errors.message.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send"}
</button>
</form>
);
}A few things to notice:
useForminfers its field types from the resolver, soregister("nmae")is a type error.mode: "onTouched"validates a field after the user leaves it, then re-validates on every change. It is a friendlier default than validating on every keystroke from the start.noValidateturns off the browser's built-in validation bubbles so Zod's messages are the only ones shown.isSubmittingstaystruewhile the asynconSubmitruns, which makes double submits easy to prevent.
Zod 4 changes that affect forms
If you learned Zod with version 3, a few APIs moved. From the official Zod 4 migration guide:
| Zod 3 | Zod 4 |
|---|---|
z.string().email() |
z.email() (method form is deprecated) |
z.string().url() |
z.url() |
{ message: "..." } |
{ error: "..." } (message still works, deprecated) |
invalid_type_error, required_error |
Removed, use the error param (it can be a function) |
.strict() / .passthrough() |
z.strictObject() / z.looseObject() |
.merge() |
.extend() |
error.flatten() / error.format() |
z.treeifyError(error) |
The error param can also be a function, which is handy for type errors:
import { z } from "zod";
const age = z.number({
error: (issue) => (issue.input === undefined ? "Age is required" : "Age must be a number"),
});Cross-field validation: passwords that must match
Use .refine() on the object and point the error at a specific field with path.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
export const signUpSchema = z
.object({
email: z.email({ error: "Enter a valid email" }),
password: z
.string()
.min(8, { error: "At least 8 characters" })
.regex(/[0-9]/, { error: "Include at least one number" }),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
error: "Passwords do not match",
path: ["confirmPassword"],
});
export function SignUpForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(signUpSchema),
defaultValues: { email: "", password: "", confirmPassword: "" },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))} noValidate>
<input type="email" {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p role="alert">{errors.password.message}</p>}
<input type="password" {...register("confirmPassword")} />
{errors.confirmPassword && <p role="alert">{errors.confirmPassword.message}</p>}
<button type="submit">Create account</button>
</form>
);
}Gotcha: this behavior changed between versions. In Zod 3, an object-level refinement only ran once every field passed, so "passwords do not match" stayed hidden until the email was valid. In Zod 4, failed checks like .min() or an invalid email format are "continuable", so the refinement still runs and both errors show together. A type error (for example, a field that is undefined instead of a string) still skips the refinement. Zod 4 also adds a when option on refinements if you need finer control. With string defaultValues in React Hook Form, you will rarely hit the type-error case.
Numbers, dates and transforms
HTML inputs return strings, even type="number". You have two clean options.
Option 1: let React Hook Form convert, keep the schema strict.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const bookingSchema = z.object({
guests: z
.number({ error: "Enter the number of guests" })
.int()
.min(1, { error: "At least 1 guest" })
.max(10, { error: "Maximum 10 guests" }),
});
export function GuestsField() {
const {
register,
formState: { errors },
} = useForm({
resolver: zodResolver(bookingSchema),
defaultValues: { guests: 1 },
});
return (
<>
<input type="number" {...register("guests", { valueAsNumber: true })} />
{errors.guests && <p role="alert">{errors.guests.message}</p>}
</>
);
}An empty number input with valueAsNumber gives NaN, which z.number() rejects, so the "Enter the number of guests" message shows.
Option 2: coerce in the schema. z.coerce.number() converts the string for you. In Zod 4 the input type of a coerced schema is unknown, so your form's input type and output type now differ. React Hook Form handles this: useForm accepts separate input and output types, and handleSubmit gives you the parsed output.
import { z } from "zod";
const priceFilterSchema = z.object({
maxPrice: z.coerce.number().positive(),
from: z.coerce.date(),
});
type PriceFilterInput = z.input<typeof priceFilterSchema>; // { maxPrice: unknown; from: unknown }
type PriceFilterOutput = z.output<typeof priceFilterSchema>; // { maxPrice: number; from: Date }The same input-versus-output split applies to .transform() and .default(). When your schema transforms values, type things with z.input and z.output, not just z.infer (which is the output type).
Dynamic fields with useFieldArray
For repeating groups (line items, phone numbers, guests), combine z.array() with useFieldArray.
import { useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const invoiceSchema = z.object({
items: z
.array(
z.object({
description: z.string().min(1, { error: "Required" }),
amount: z.number({ error: "Enter an amount" }).positive({ error: "Must be positive" }),
}),
)
.min(1, { error: "Add at least one item" }),
});
export function InvoiceForm() {
const {
control,
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(invoiceSchema),
defaultValues: { items: [{ description: "", amount: 0 }] },
});
const { fields, append, remove } = useFieldArray({ control, name: "items" });
return (
<form onSubmit={handleSubmit((data) => console.log(data))} noValidate>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.description`)} />
{errors.items?.[index]?.description && (
<p role="alert">{errors.items[index].description.message}</p>
)}
<input type="number" step="0.01" {...register(`items.${index}.amount`, { valueAsNumber: true })} />
{errors.items?.[index]?.amount && (
<p role="alert">{errors.items[index].amount.message}</p>
)}
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
{errors.items?.root && <p role="alert">{errors.items.root.message}</p>}
<button type="button" onClick={() => append({ description: "", amount: 0 })}>
Add item
</button>
<button type="submit">Save</button>
</form>
);
}Use field.id as the key, never the index. React Hook Form generates stable IDs so removing a row does not mix up the inputs. Array-level errors such as "Add at least one item" appear under errors.items.root.
Showing server-side errors with setError
Client validation is for user experience. The server still decides. When the API rejects a value (say, the email is already taken), map it back to the field:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const registerSchema = z.object({
email: z.email({ error: "Enter a valid email" }),
});
export function RegisterForm() {
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm({
resolver: zodResolver(registerSchema),
defaultValues: { email: "" },
});
async function onSubmit(values: z.infer<typeof registerSchema>) {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (res.status === 409) {
setError("email", { type: "server", message: "This email is already registered" });
return;
}
if (!res.ok) {
setError("root.server", { type: "server", message: "Something went wrong. Try again." });
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input type="email" {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
<button type="submit">Register</button>
</form>
);
}Reusing the schema on the server
Put schemas in a shared file and validate the request body with safeParse. Here is a framework-agnostic handler using the standard Request and Response APIs (it works as a Next.js route handler):
import { z } from "zod";
const contactSchema = z.object({
name: z.string().trim().min(2),
email: z.email(),
message: z.string().min(10).max(1000),
});
export async function POST(request: Request) {
const result = contactSchema.safeParse(await request.json());
if (!result.success) {
return Response.json({ errors: z.treeifyError(result.error) }, { status: 400 });
}
// result.data is fully typed here
return Response.json({ ok: true });
}If you use Next.js Server Actions, the same safeParse call goes at the top of the action. I cover that pattern in the Next.js Server Actions guide, and React 19's form hooks in useActionState and useOptimistic.
Common mistakes and gotchas
- Missing
defaultValues. Without them, inputs start asundefined,reset()has nothing to reset to, and React may warn about switching from uncontrolled to controlled. - Spreading
registeronto custom components that do not forwardref. Component libraries that wrap inputs needController(oruseController) instead. - Validating only on the client. Anyone can call your API directly. Parse on the server too.
- Using
z.inferfor a schema with transforms or coerce. That gives you the output type. Your form inputs needz.input. - Mixing Zod 3 and Zod 4 tutorials. If you see
required_erroror.email()on a string, the example is Zod 3 style. - Watching the whole form.
watch()with no arguments re-renders on every keystroke. PreferuseWatch({ name: "field" })for specific fields.
FAQ
How do I use Zod with React Hook Form?
Install zod, react-hook-form and @hookform/resolvers, define a Zod schema, and pass zodResolver(schema) as the resolver option to useForm. React Hook Form then runs the schema on validation and exposes Zod's error messages in formState.errors.
Does @hookform/resolvers support Zod 4?
Yes. Current versions of @hookform/resolvers accept both Zod 3 and Zod 4 schemas through the same zodResolver import from @hookform/resolvers/zod.
Why is my number field a string in React Hook Form?
HTML inputs always return strings. Either register the field with valueAsNumber: true and validate it with z.number(), or use z.coerce.number() in the schema, keeping in mind that coerce changes the input type to unknown in Zod 4.
Can I reuse the same Zod schema on the server?
Yes, and you should. Export the schema from a shared module and call schema.safeParse() in your API route or Server Action, so client and server enforce the same rules.
Wrapping up
React Hook Form handles form state and performance, Zod handles rules and types, and zodResolver joins them. Keep one schema per form in a shared file, use it on both client and server, and remember the Zod 4 naming changes. Most form bugs I see come from types and validation drifting apart, and this setup stops that from happening.
- #react
- #forms
- #zod
- #typescript
- #validation
Keep reading
React8 min read
React 19 Hooks: use, useActionState, useOptimistic Explained
React 19 hooks explained with TypeScript examples: use, useActionState, useOptimistic and useFormStatus, plus the gotchas that trip people up in real forms.
Read articleReact7 min read
10 Custom React Hooks Worth Having (TypeScript Examples)
10 custom React hooks in TypeScript: useDebounce, useLocalStorage, useMediaQuery, useOnClickOutside, useInterval and more, built for React 19 and the compiler.
Read articleReact8 min read
React Compiler: Do You Still Need useMemo and useCallback?
React Compiler 1.0 memoizes components for you. Learn when useMemo and useCallback are still needed, how to enable it in Vite or Next.js 16, and what breaks.
Read article