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.
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.
// 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.
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.
// 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.
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.
// 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.
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.
// 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 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.
// 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: "ada@example.com",
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.

