Form handling is one of the most critical aspects of any web application. Whether you are building a contact form, a registration flow, or a complex multi-step wizard, you need robust validation that works on both the client and server. In this guide, we will explore how to combine React Hook Form and Zod in a Next.js 16 application to create forms that are type-safe, accessible, performant, and validated end-to-end.
React Hook Form provides an ergonomic API for managing form state with minimal re-renders, while Zod offers a TypeScript-first schema validation library that works seamlessly on both client and server. Together, they create a powerful form validation pipeline that catches errors early and provides excellent developer experience.
Why React Hook Form + Zod?
Choosing the right form library and validation strategy is an architectural decision that affects developer productivity, user experience, and code maintainability. Here is why React Hook Form and Zod make an exceptional pairing:
React Hook Form Advantages
- Minimal re-renders: Unlike controlled form libraries that re-render on every keystroke, React Hook Form uses uncontrolled inputs by default, resulting in significantly better performance for complex forms.
- Small bundle size: At approximately 9KB minified and gzipped, React Hook Form adds minimal overhead to your application bundle.
- No dependencies: The core library has zero dependencies, reducing supply chain risk and version conflicts.
- Flexible validation: Supports native HTML validation, custom validation functions, and schema-based validation through resolvers.
- Excellent TypeScript support: Full type inference for form values, errors, and field registration.
Zod Advantages
- TypeScript-first: Zod schemas automatically infer TypeScript types, eliminating the need to maintain separate type definitions and validation rules.
- Composable: Schemas can be combined, extended, and transformed using a fluent API that reads like natural language.
- Runtime validation: Unlike TypeScript types which are erased at compile time, Zod validates data at runtime, making it perfect for validating user input and API responses.
- Isomorphic: The same schema works identically on client and server, ensuring consistent validation rules across your entire stack.
- Custom error messages: Zod supports custom error messages per validation rule, making it easy to provide user-friendly feedback in multiple languages.
The Combination
When you connect Zod to React Hook Form via the @hookform/resolvers package, you get a single source of truth for both your TypeScript types and your validation rules. Define a schema once, and it generates your form types, validates client-side input in real-time, and validates server-side submissions identically.
Installation
Install the required packages in your Next.js 16 project:
npm install react-hook-form zod @hookform/resolversHere is what each package provides:
react-hook-form— Core form management library with hooks for registration, submission, and error handling.zod— Schema declaration and validation library for TypeScript.@hookform/resolvers— Adapter package that connects external validation libraries (Zod, Yup, Joi, etc.) to React Hook Form.
No additional configuration is needed. These packages work out of the box with the Next.js App Router and React Server Components.
Creating a Schema with Zod
A Zod schema defines the shape, types, and validation rules for your form data. Start by creating a schema file that can be shared between client and server:
// lib/schemas/contact.ts
import { z } from "zod";
export const contactSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(100, "Name must be less than 100 characters")
.trim(),
email: z
.string()
.email("Please enter a valid email address")
.toLowerCase(),
phone: z
.string()
.regex(
/^+?[1-9]d{1,14}$/,
"Please enter a valid phone number"
)
.optional()
.or(z.literal("")),
subject: z.enum(
["general", "project", "consulting", "other"],
{
errorMap: () => ({ message: "Please select a subject" }),
}
),
message: z
.string()
.min(10, "Message must be at least 10 characters")
.max(2000, "Message must be less than 2000 characters")
.trim(),
privacyPolicy: z.literal(true, {
errorMap: () => ({
message: "You must accept the privacy policy",
}),
}),
});
// Infer the TypeScript type from the schema
export type ContactFormData = z.infer<typeof contactSchema>;This schema defines a contact form with the following validations:
- name: Required string, 2-100 characters, automatically trimmed
- email: Required valid email address, automatically lowercased
- phone: Optional E.164 phone number format (allows empty string)
- subject: Required enum from a predefined list of options
- message: Required string, 10-2000 characters, automatically trimmed
- privacyPolicy: Must be exactly
true(checkbox must be checked)
The z.infer utility extracts the TypeScript type from the schema, so you never need to maintain a separate interface. When the schema changes, the type updates automatically.
Setting Up the Form
With the schema defined, create the form component using useForm with the Zod resolver:
// components/common/ContactForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, type ContactFormData } from "@/lib/schemas/contact";
export default function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
reset,
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: {
name: "",
email: "",
phone: "",
subject: undefined,
message: "",
privacyPolicy: false,
},
mode: "onBlur", // Validate fields when they lose focus
reValidateMode: "onChange", // Re-validate on change after first error
});
async function onSubmit(data: ContactFormData) {
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("Failed to send message");
}
reset(); // Clear form on success
} catch (error) {
console.error("Form submission error:", error);
}
}
return (
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
aria-label="Contact form"
>
{/* Form fields go here */}
</form>
);
}
Key configuration options for useForm:
- resolver: Connects the Zod schema to React Hook Form via
zodResolver. All validation rules come from the schema. - defaultValues: Initial values for all fields. Always provide defaults to prevent controlled/uncontrolled input warnings.
- mode: Controls when validation triggers.
"onBlur"provides a good balance between immediate feedback and not overwhelming the user while typing. - reValidateMode: After a field has been validated and shows an error,
"onChange"re-validates on each keystroke so the error clears as soon as the input becomes valid.
Fields and Error Messages
Each form field uses the register function to connect to React Hook Form, and errors are displayed with proper ARIA attributes for accessibility:
<div className="space-y-1">
<label
htmlFor="contact-name"
className="block text-sm font-medium text-gray-700"
>
Name *
</label>
<input
id="contact-name"
type="text"
{...register("name")}
aria-invalid={errors.name ? "true" : "false"}
aria-describedby={errors.name ? "name-error" : undefined}
className={cn(
"w-full rounded-md border px-4 py-2",
"focus:outline-none focus:ring-2 focus:ring-primary",
errors.name
? "border-red-500 focus:ring-red-500"
: "border-gray-300"
)}
/>
{errors.name && (
<p
id="name-error"
role="alert"
className="text-sm text-red-600"
>
{errors.name.message}
</p>
)}
</div>For select fields, the registration works the same way:
<div className="space-y-1">
<label
htmlFor="contact-subject"
className="block text-sm font-medium text-gray-700"
>
Subject *
</label>
<select
id="contact-subject"
{...register("subject")}
aria-invalid={errors.subject ? "true" : "false"}
aria-describedby={
errors.subject ? "subject-error" : undefined
}
className={cn(
"w-full rounded-md border px-4 py-2",
errors.subject
? "border-red-500"
: "border-gray-300"
)}
>
<option value="">Select a subject</option>
<option value="general">General Inquiry</option>
<option value="project">Project Request</option>
<option value="consulting">Consulting</option>
<option value="other">Other</option>
</select>
{errors.subject && (
<p
id="subject-error"
role="alert"
className="text-sm text-red-600"
>
{errors.subject.message}
</p>
)}
</div>For the checkbox field, handle the boolean registration:
<div className="flex items-start gap-2">
<input
id="contact-privacy"
type="checkbox"
{...register("privacyPolicy")}
aria-invalid={errors.privacyPolicy ? "true" : "false"}
aria-describedby={
errors.privacyPolicy ? "privacy-error" : undefined
}
className="mt-1 h-4 w-4 rounded border-gray-300"
/>
<label
htmlFor="contact-privacy"
className="text-sm text-gray-600"
>
I accept the privacy policy *
</label>
{errors.privacyPolicy && (
<p
id="privacy-error"
role="alert"
className="text-sm text-red-600"
>
{errors.privacyPolicy.message}
</p>
)}
</div>Server-Side Validation with Server Actions
Client-side validation improves user experience, but server-side validation is mandatory for security. Never trust client-side data. In Next.js 16, you can use Server Actions to validate form data on the server using the same Zod schema:
// app/actions/contact.ts
"use server";
import { contactSchema } from "@/lib/schemas/contact";
interface ActionResult {
success: boolean;
message: string;
errors?: Record<string, string[]>;
}
export async function submitContactForm(
formData: FormData
): Promise<ActionResult> {
const rawData = {
name: formData.get("name"),
email: formData.get("email"),
phone: formData.get("phone"),
subject: formData.get("subject"),
message: formData.get("message"),
privacyPolicy: formData.get("privacyPolicy") === "on",
};
const result = contactSchema.safeParse(rawData);
if (!result.success) {
const fieldErrors: Record<string, string[]> = {};
for (const issue of result.error.issues) {
const field = issue.path[0] as string;
if (!fieldErrors[field]) {
fieldErrors[field] = [];
}
fieldErrors[field].push(issue.message);
}
return {
success: false,
message: "Validation failed. Please correct the errors.",
errors: fieldErrors,
};
}
// Process the validated data
const validatedData = result.data;
try {
// Send email via Mailjet or other service
await sendEmail(validatedData);
return {
success: true,
message: "Your message has been sent successfully!",
};
} catch (error) {
return {
success: false,
message: "Failed to send message. Please try again.",
};
}
}You can also validate data in API route handlers for REST-style endpoints:
// app/api/contact/route.ts
import { NextResponse } from "next/server";
import { contactSchema } from "@/lib/schemas/contact";
export async function POST(request: Request) {
try {
const body = await request.json();
const result = contactSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: "Validation failed",
details: result.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
const data = result.data;
// Process validated data...
return NextResponse.json(
{ message: "Message sent successfully" },
{ status: 200 }
);
} catch (error) {
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
The key principle is that the same contactSchema is used for both client and server validation. If you update a validation rule in the schema, it automatically applies everywhere.
Complete Contact Form
Here is a complete, production-ready contact form component that brings together all the patterns discussed so far:
// components/common/ContactForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { cn } from "@/utils/classNames";
import {
contactSchema,
type ContactFormData,
} from "@/lib/schemas/contact";
interface ContactFormProps {
onSuccess?: () => void;
}
export default function ContactForm({ onSuccess }: ContactFormProps) {
const t = useTranslations("contact.form");
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
reset,
setError,
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: {
name: "",
email: "",
phone: "",
subject: undefined,
message: "",
privacyPolicy: false,
},
mode: "onBlur",
reValidateMode: "onChange",
});
async function onSubmit(data: ContactFormData) {
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json();
// Map server validation errors to form fields
if (errorData.details) {
Object.entries(errorData.details).forEach(
([field, messages]) => {
setError(field as keyof ContactFormData, {
type: "server",
message: (messages as string[])[0],
});
}
);
return;
}
throw new Error(errorData.error || "Submission failed");
}
reset();
onSuccess?.();
} catch (error) {
setError("root", {
type: "server",
message: t("error"),
});
}
}
if (isSubmitSuccessful) {
return (
<div role="status" className="rounded-lg bg-green-50 p-6 text-center">
<p className="text-green-800 font-medium">{t("success")}</p>
</div>
);
}
return (
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
aria-label={t("aria_label")}
className="space-y-6"
>
{errors.root && (
<div role="alert" className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-700">
{errors.root.message}
</p>
</div>
)}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-1">
<label htmlFor="name" className="block text-sm font-medium">
{t("name")} *
</label>
<input
id="name"
type="text"
{...register("name")}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-error" : undefined}
className={cn(
"w-full rounded-md border px-4 py-2",
"focus-visible:outline-none focus-visible:ring-2",
errors.name
? "border-red-500 focus-visible:ring-red-500"
: "border-gray-300 focus-visible:ring-primary"
)}
/>
{errors.name && (
<p id="name-error" role="alert" className="text-sm text-red-600">
{errors.name.message}
</p>
)}
</div>
<div className="space-y-1">
<label htmlFor="email" className="block text-sm font-medium">
{t("email")} *
</label>
<input
id="email"
type="email"
{...register("email")}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
className={cn(
"w-full rounded-md border px-4 py-2",
"focus-visible:outline-none focus-visible:ring-2",
errors.email
? "border-red-500 focus-visible:ring-red-500"
: "border-gray-300 focus-visible:ring-primary"
)}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-red-600">
{errors.email.message}
</p>
)}
</div>
</div>
<div className="space-y-1">
<label htmlFor="message" className="block text-sm font-medium">
{t("message")} *
</label>
<textarea
id="message"
rows={5}
{...register("message")}
aria-invalid={!!errors.message}
aria-describedby={errors.message ? "message-error" : undefined}
className={cn(
"w-full rounded-md border px-4 py-2 resize-none",
"focus-visible:outline-none focus-visible:ring-2",
errors.message
? "border-red-500 focus-visible:ring-red-500"
: "border-gray-300 focus-visible:ring-primary"
)}
/>
{errors.message && (
<p id="message-error" role="alert" className="text-sm text-red-600">
{errors.message.message}
</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
className={cn(
"w-full rounded-md bg-primary px-6 py-3 text-white font-medium",
"hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2",
"disabled:opacity-50 disabled:cursor-not-allowed",
"transition-colors duration-200"
)}
>
{isSubmitting ? t("submitting") : t("submit")}
</button>
</form>
);
}Advanced Patterns
Real-world forms often require more than simple field validation. Here are advanced patterns that handle complex form scenarios.
Conditional Validation
Use Zod's .refine() and .superRefine() methods for validation rules that depend on other fields:
const registrationSchema = z
.object({
accountType: z.enum(["personal", "business"]),
companyName: z.string().optional(),
taxId: z.string().optional(),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
// Require company fields for business accounts
if (data.accountType === "business") {
if (!data.companyName || data.companyName.length < 2) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Company name is required for business accounts",
path: ["companyName"],
});
}
if (!data.taxId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Tax ID is required for business accounts",
path: ["taxId"],
});
}
}
// Password confirmation
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["confirmPassword"],
});
}
});Dynamic Field Arrays
Use React Hook Form's useFieldArray for dynamic lists of fields, such as adding multiple phone numbers or addresses:
"use client";
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const teamSchema = z.object({
teamName: z.string().min(2),
members: z
.array(
z.object({
name: z.string().min(2, "Member name is required"),
role: z.string().min(2, "Role is required"),
email: z.string().email("Valid email required"),
})
)
.min(1, "At least one team member is required")
.max(10, "Maximum 10 team members"),
});
type TeamFormData = z.infer<typeof teamSchema>;
export default function TeamForm() {
const { register, handleSubmit, control, formState: { errors } } =
useForm<TeamFormData>({
resolver: zodResolver(teamSchema),
defaultValues: {
teamName: "",
members: [{ name: "", role: "", email: "" }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "members",
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("teamName")} placeholder="Team name" />
{fields.map((field, index) => (
<div key={field.id} className="flex gap-4">
<input
{...register(`members.${index}.name`)}
placeholder="Name"
aria-label={`Member ${index + 1} name`}
/>
<input
{...register(`members.${index}.role`)}
placeholder="Role"
aria-label={`Member ${index + 1} role`}
/>
<input
{...register(`members.${index}.email`)}
placeholder="Email"
aria-label={`Member ${index + 1} email`}
/>
<button
type="button"
onClick={() => remove(index)}
aria-label={`Remove member ${index + 1}`}
>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: "", role: "", email: "" })}
>
Add Member
</button>
<button type="submit">Submit</button>
</form>
);
}Watching Field Values
Use the watch function to reactively observe field values and conditionally render UI elements:
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
export default function ConditionalForm() {
const { register, watch, handleSubmit, formState: { errors } } =
useForm({
resolver: zodResolver(schema),
});
const accountType = watch("accountType");
const messageLength = watch("message")?.length || 0;
return (
<form onSubmit={handleSubmit(onSubmit)}>
<select {...register("accountType")}>
<option value="personal">Personal</option>
<option value="business">Business</option>
</select>
{accountType === "business" && (
<input
{...register("companyName")}
placeholder="Company Name"
/>
)}
<textarea {...register("message")} maxLength={2000} />
<p className="text-sm text-gray-500">
{messageLength}/2000 characters
</p>
</form>
);
}Form Accessibility
Accessible forms are not optional. They are a requirement for reaching all users, including those who rely on assistive technologies. Here are the key accessibility patterns to implement in every form:
ARIA Invalid and Described By
Every input with a potential error state must use aria-invalid and aria-describedby to connect the input to its error message:
<input
id="email"
type="email"
{...register("email")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={
errors.email
? "email-error"
: "email-hint"
}
/>
<p id="email-hint" className="text-sm text-gray-500">
We will never share your email address.
</p>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-red-600">
{errors.email.message}
</p>
)}Live Regions for Dynamic Errors
Use role="alert" on error messages so screen readers announce them immediately when they appear. This is critical for validation errors that appear after the user interacts with a field.
Focus Management
When a form submission fails, move focus to the first field with an error. React Hook Form provides the setFocus function for this:
const { setFocus } = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
});
async function onSubmit(data: ContactFormData) {
try {
await submitForm(data);
} catch (error) {
// Focus the first field with an error
const firstError = Object.keys(errors)[0] as keyof ContactFormData;
if (firstError) {
setFocus(firstError);
}
}
}Required Field Indicators
Always indicate required fields visually and programmatically. Use aria-required="true" on required inputs, and provide a visual indicator (typically an asterisk) with a legend explaining the convention:
<fieldset>
<legend className="sr-only">
Fields marked with * are required
</legend>
<label htmlFor="name">
Name <span aria-hidden="true">*</span>
</label>
<input
id="name"
{...register("name")}
aria-required="true"
aria-invalid={!!errors.name}
/>
</fieldset>Submit Button States
Communicate the submission state to assistive technologies using aria-busy and aria-disabled:
<button
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
aria-label={
isSubmitting
? "Sending your message, please wait"
: "Send message"
}
>
{isSubmitting ? (
<>
<span className="sr-only">Sending...</span>
<span aria-hidden="true">Sending...</span>
</>
) : (
"Send Message"
)}
</button>Best Practices
Drawing from production experience with React Hook Form and Zod, here are the practices that lead to reliable, maintainable form implementations:
1. Single Source of Truth for Validation
Define your Zod schema in a shared location (e.g., /lib/schemas/) and import it in both client components and server actions. Never duplicate validation rules between client and server.
2. Use Internationalized Error Messages
For multilingual applications, generate error messages through your i18n system rather than hardcoding them in the schema:
// Create a schema factory that accepts translated messages
export function createContactSchema(t: (key: string) => string) {
return z.object({
name: z
.string()
.min(2, t("name_min_length"))
.max(100, t("name_max_length")),
email: z
.string()
.email(t("email_invalid")),
message: z
.string()
.min(10, t("message_min_length")),
});
}3. Handle Network Errors Gracefully
Always wrap form submissions in try-catch blocks and display user-friendly error messages. Use React Hook Form's setError("root", ...) for general form-level errors that are not tied to a specific field.
4. Debounce Expensive Validations
For validations that require async operations (e.g., checking if an email is already registered), debounce the validation to avoid excessive API calls:
const emailSchema = z
.string()
.email()
.refine(
async (email) => {
const response = await fetch(
`/api/check-email?email=${encodeURIComponent(email)}`
);
const { available } = await response.json();
return available;
},
{ message: "This email is already registered" }
);5. Provide Immediate Visual Feedback
Use the mode: "onBlur" and reValidateMode: "onChange" combination to show errors after the user leaves a field, then clear them as soon as the input becomes valid. This provides immediate feedback without being intrusive during typing.
6. Sanitize All User Input on the Server
Even with Zod validation, always sanitize user input before processing. Use HTML entity escaping for any values that will be rendered in emails or stored in a database:
function sanitize(input: string): string {
return input
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}7. Test Form Edge Cases
Consider these common edge cases when building forms:
- Double-click on submit button (use
isSubmittingto disable) - Network timeout during submission
- Server returning validation errors not in the schema
- Very long input values that exceed database column limits
- Unicode characters, emojis, and right-to-left text
- Autofill behavior from password managers
- Form resubmission after browser back/forward navigation
8. Keep Forms Focused
Resist the temptation to collect every possible data point in a single form. Short forms have higher completion rates. If you need extensive data, consider a multi-step wizard with progress indication and the ability to save progress.
9. Leverage TypeScript End-to-End
The combination of Zod's z.infer and React Hook Form's generics creates a fully typed pipeline from schema to form values to submission handler. Take advantage of this by never using any types in your form code:
// The type flows from schema to form to handler
const schema = z.object({ name: z.string() });
type FormData = z.infer<typeof schema>; // { name: string }
const { handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema),
});
// data is fully typed as { name: string }
handleSubmit((data) => {
console.log(data.name); // TypeScript knows this is a string
});10. Progressive Enhancement
Consider making your forms work without JavaScript by using standard HTML form attributes alongside React Hook Form. The noValidate attribute disables browser validation in favor of your custom validation, but the form can still submit via standard POST if JavaScript fails to load. Server actions in Next.js 16 support this pattern natively through the action prop on forms.
By following these patterns and principles, you will build forms that are robust, accessible, type-safe, and maintainable. The combination of React Hook Form and Zod provides an excellent foundation that scales from simple contact forms to complex multi-step workflows without sacrificing developer experience or user satisfaction.