Forms are the most tedious part of any web application. Validation logic gets scattered across handlers, error messages drift out of sync with actual constraints, and TypeScript types end up duplicated between the schema and the component. Zod and React Hook Form solve this together: Zod defines the shape and rules of your data as a single schema, React Hook Form handles rendering and performance, and @hookform/resolvers bridges the two so your types, validation, and UI stay in lockstep.
Zod: schemas that double as types
Zod is a TypeScript-first validation library. You define a schema once and get both runtime validation and a static type from it, no duplication needed.
import { z } from 'zod';
export const contactSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters'),
budget: z.enum(['small', 'medium', 'large']),
});
export type ContactFormData = z.infer<typeof contactSchema>;ContactFormData is derived directly from the schema. If you add a field to the schema, the type updates automatically. If you change a constraint, the runtime validation follows. One source of truth for everything.
React Hook Form: performance by default
React Hook Form tracks field state through uncontrolled inputs and refs, which means the form doesn't re-render the entire tree on every keystroke. For forms with many fields or complex layouts, this makes a real difference compared to controlled approaches.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { contactSchema, type ContactFormData } from '@/schemas/contact';
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
});
const onSubmit = (data: ContactFormData) => {
// data is fully typed and validated here
console.log(data);
};
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>}
<select {...register('budget')}>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</select>
<button type="submit">Send</button>
</form>
);
}The zodResolver connects Zod's validation to React Hook Form's error system. Every error message you defined in the schema flows directly into formState.errors, with full type safety on field names.
Composing complex schemas
Real-world forms rarely stay flat. Zod's composition API handles nested objects, conditional fields, and cross-field validation without breaking the type inference chain.
Nested objects and arrays
const milestoneSchema = z.object({
title: z.string().min(1),
dueDate: z.string().date(),
});
const projectSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
milestones: z.array(milestoneSchema).min(1, 'Add at least one milestone'),
});Cross-field validation with refine
Sometimes one field depends on another. Zod's refine and superRefine methods let you express these constraints at the schema level instead of burying them in submit handlers.
const dateRangeSchema = z.object({
startDate: z.string().date(),
endDate: z.string().date(),
}).refine(
(data) => new Date(data.endDate) > new Date(data.startDate),
{ message: 'End date must be after start date', path: ['endDate'] }
);The path option tells React Hook Form exactly which field should display the error. Without it, the error would be attached to the root of the form, invisible to the user.
Dynamic fields with useFieldArray
Forms with repeatable sections, like adding multiple team members or line items, combine useFieldArray from React Hook Form with Zod's array validation.
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const teamSchema = z.object({
members: z.array(z.object({
name: z.string().min(1, 'Name is required'),
role: z.string().min(1, 'Role is required'),
})).min(1, 'Add at least one member'),
});
type TeamFormData = z.infer<typeof teamSchema>;
export function TeamForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<TeamFormData>({
resolver: zodResolver(teamSchema),
defaultValues: { members: [{ name: '', role: '' }] },
});
const { fields, append, remove } = useFieldArray({ control, name: 'members' });
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`members.${index}.name`)} />
{errors.members?.[index]?.name && (
<span>{errors.members[index]?.name?.message}</span>
)}
<input {...register(`members.${index}.role`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', role: '' })}>
Add member
</button>
<button type="submit">Submit</button>
</form>
);
}Every field in the dynamic array is individually validated. Add a member with an empty name, and only that specific row shows an error. The types flow through correctly even for deeply nested indexed access like members.${index}.name.
Server-side reuse
One of Zod's strongest advantages is that schemas work identically on the server. The same contactSchema you use in your form component can validate the incoming request in your API route or Server Action.
import { contactSchema } from '@/schemas/contact';
export async function POST(request: Request) {
const body = await request.json();
const result = contactSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
// result.data is fully typed as ContactFormData
await sendEmail(result.data);
return Response.json({ success: true });
}safeParse returns a discriminated union: either { success: true, data } or { success: false, error }. No try/catch needed, and TypeScript narrows the type automatically after the check. Client and server validate against the exact same rules, from the exact same code.
Conclusion
Zod and React Hook Form complement each other precisely where it matters. Zod owns the data shape and validation rules, React Hook Form owns the rendering and UX. The result is forms where types, constraints, and error messages are defined once and stay consistent from the browser to the API. Once you've built forms this way, going back to manual validation feels like writing code with one hand tied behind your back.