Type-Safe Forms with React Hook Form and Zod

Photo by Major Nelson on flickr
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.
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.
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.
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.
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.

Photo by Major Nelson on flickr
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.
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.
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.
// schemas/contact-form.ts
import { z } from "zod"
export const contactFormSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Enter a valid email address"),
company: z.string().optional(),
message: z.string().min(10, "Message must be at least 10 characters"),
})
// Infer the TypeScript type from the schema — one source of truth
export type ContactFormValues = z.infer<typeof contactFormSchema>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.
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.
// components/ContactForm.tsx
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { contactFormSchema, type ContactFormValues } from "@/schemas/contact-form"
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ContactFormValues>({
resolver: zodResolver(contactFormSchema),
mode: "onBlur",
})
const onSubmit = async (values: ContactFormValues) => {
const res = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(values),
})
if (!res.ok) throw new Error("Submission failed")
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<textarea {...register("message")} />
{errors.message && <span>{errors.message.message}</span>}
<button type="submit" disabled={isSubmitting}>
Send
</button>
</form>
)
}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.
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:
// app/api/contact/route.ts
import { NextResponse } from "next/server"
import { contactFormSchema } from "@/schemas/contact-form"
export async function POST(request: Request) {
const body = await request.json()
const result = contactFormSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
)
}
// result.data is fully typed as ContactFormValues here
await sendEmail(result.data)
return NextResponse.json({ ok: true })
}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.
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. |
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.
// schemas/signup-form.ts
import { z } from "zod"
export const signupFormSchema = z
.object({
email: z.string().email(),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
})
export type SignupFormValues = z.infer<typeof signupFormSchema>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 handful of habits separate a form that looks type-safe from one that actually is. Run through these before merging:
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.
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.
// schemas/contact-form.test.ts
import { describe, it, expect } from "vitest"
import { contactFormSchema } from "./contact-form"
describe("contactFormSchema", () => {
it("rejects an invalid email", () => {
const result = contactFormSchema.safeParse({
name: "Ada",
email: "not-an-email",
message: "Hello there, this is a test.",
})
expect(result.success).toBe(false)
})
it("accepts a valid payload", () => {
const result = contactFormSchema.safeParse({
name: "Ada",
email: "[email protected]",
message: "Hello there, this is a test.",
})
expect(result.success).toBe(true)
})
})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.