Full-Stack Form Validation with React Hook Form and Zod
Build type-safe forms with React Hook Form and Zod, sharing a single validation schema between client and server for bulletproof data integrity.
Tags
Full-Stack Form Validation with React Hook Form and Zod
In this tutorial, you will build a complete form validation system that shares a single Zod schema between the client and server. You will learn how to integrate React Hook Form with Zod for instant client-side feedback, validate the same data on your API route for security, handle complex form patterns like dynamic arrays and nested objects, and display errors in a user-friendly way.
Forms are the primary way users interact with web applications, and validation is the gatekeeper that ensures data integrity. The traditional approach of writing validation logic twice, once on the client and once on the server, is error-prone and tedious. Zod solves this by letting you define your rules once and use them everywhere.
TL;DR
Install zod, react-hook-form, and @hookform/resolvers. Define a Zod schema that describes your form data. Use zodResolver to connect the schema to React Hook Form. Import the same schema in your Next.js API route to validate the request body. One schema, two validation layers, zero duplication.
Prerequisites
- ›Next.js 14+ with the App Router
- ›TypeScript configured in your project
- ›Familiarity with React components and hooks
Install the required packages:
npm install react-hook-form zod @hookform/resolversStep 1: Define the Zod Schema
Create a shared validation schema. This is the single source of truth for what constitutes valid form data.
// schemas/registration.ts
import { z } from "zod";
export const registrationSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(50, "Name must be under 50 characters")
.trim(),
email: z
.string()
.email("Please enter a valid email address")
.toLowerCase(),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[0-9]/, "Password must contain at least one number")
.regex(/[^A-Za-z0-9]/, "Password must contain at least one special character"),
confirmPassword: z.string(),
role: z.enum(["developer", "designer", "manager"], {
errorMap: () => ({ message: "Please select a valid role" }),
}),
bio: z
.string()
.max(500, "Bio must be under 500 characters")
.optional(),
agreeToTerms: z
.boolean()
.refine((val) => val === true, "You must agree to the terms"),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
// Infer the TypeScript type directly from the schema
export type RegistrationData = z.infer<typeof registrationSchema>;Notice the .refine() at the end for cross-field validation. The path option tells Zod which field should display the error. The z.infer utility generates the TypeScript type automatically, so you never have to maintain a separate interface.
Step 2: Build the Form Component
Create a form component that uses React Hook Form with the Zod resolver.
// components/RegistrationForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { registrationSchema, type RegistrationData } from "@/schemas/registration";
import { useState } from "react";
export function RegistrationForm() {
const [serverError, setServerError] = useState<string | null>(null);
const [isSuccess, setIsSuccess] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<RegistrationData>({
resolver: zodResolver(registrationSchema),
defaultValues: {
name: "",
email: "",
password: "",
confirmPassword: "",
role: undefined,
bio: "",
agreeToTerms: false,
},
});
async function onSubmit(data: RegistrationData) {
setServerError(null);
try {
const response = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) {
setServerError(result.error || "Registration failed");
return;
}
setIsSuccess(true);
reset();
} catch {
setServerError("Network error. Please try again.");
}
}
if (isSuccess) {
return (
<div className="rounded-lg border border-green-600 bg-green-900/20 p-6">
<p className="text-green-400">Registration successful!</p>
</div>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{serverError && (
<div className="rounded-lg border border-red-600 bg-red-900/20 px-4 py-3">
<p className="text-sm text-red-400">{serverError}</p>
</div>
)}
{/* Name Field */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300">
Name
</label>
<input
id="name"
type="text"
{...register("name")}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-white"
/>
{errors.name && (
<p className="mt-1 text-sm text-red-400">{errors.name.message}</p>
)}
</div>
{/* Email Field */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300">
Email
</label>
<input
id="email"
type="email"
{...register("email")}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-white"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-400">{errors.email.message}</p>
)}
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300">
Password
</label>
<input
id="password"
type="password"
{...register("password")}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-white"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-400">{errors.password.message}</p>
)}
</div>
{/* Confirm Password Field */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-white"
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-400">{errors.confirmPassword.message}</p>
)}
</div>
{/* Role Select */}
<div>
<label htmlFor="role" className="block text-sm font-medium text-gray-300">
Role
</label>
<select
id="role"
{...register("role")}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-white"
>
<option value="">Select a role</option>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Manager</option>
</select>
{errors.role && (
<p className="mt-1 text-sm text-red-400">{errors.role.message}</p>
)}
</div>
{/* Terms Checkbox */}
<div className="flex items-center gap-2">
<input
id="agreeToTerms"
type="checkbox"
{...register("agreeToTerms")}
className="rounded border-gray-700"
/>
<label htmlFor="agreeToTerms" className="text-sm text-gray-300">
I agree to the terms and conditions
</label>
</div>
{errors.agreeToTerms && (
<p className="text-sm text-red-400">{errors.agreeToTerms.message}</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? "Registering..." : "Register"}
</button>
</form>
);
}React Hook Form's register function connects each input to its internal state management. The zodResolver runs the Zod schema validation whenever the form is submitted. Errors are automatically populated in the errors object, keyed by field name.
Step 3: Server-Side Validation with the Same Schema
Never trust client-side validation alone. Users can disable JavaScript, use curl, or manipulate requests directly. Validate on the server using the exact same schema.
// app/api/register/route.ts
import { NextRequest, NextResponse } from "next/server";
import { registrationSchema } from "@/schemas/registration";
import { ZodError } from "zod";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate with the same Zod schema used on the client
const validatedData = registrationSchema.parse(body);
// At this point, validatedData is fully typed and validated
// Proceed with your business logic: save to database, send email, etc.
console.log("Validated registration:", validatedData.email);
// Simulate saving to database
// await db.users.create({ data: validatedData });
return NextResponse.json(
{ message: "Registration successful" },
{ status: 201 }
);
} catch (error) {
if (error instanceof ZodError) {
// Format Zod errors into a readable structure
const fieldErrors = error.errors.map((err) => ({
field: err.path.join("."),
message: err.message,
}));
return NextResponse.json(
{ error: "Validation failed", details: fieldErrors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}The registrationSchema.parse() call throws a ZodError if validation fails, which you catch and format into a structured error response. If parsing succeeds, validatedData is fully typed according to your schema, with all transformations like .toLowerCase() and .trim() already applied.
Step 4: Handle Complex Patterns with Dynamic Arrays
Real-world forms often include dynamic fields. Here is how to handle a form where users can add multiple skills.
// schemas/profile.ts
import { z } from "zod";
export const skillSchema = z.object({
name: z.string().min(1, "Skill name is required"),
level: z.enum(["beginner", "intermediate", "advanced"]),
years: z.number().min(0).max(50),
});
export const profileSchema = z.object({
displayName: z.string().min(2).max(30),
skills: z
.array(skillSchema)
.min(1, "Add at least one skill")
.max(10, "Maximum 10 skills allowed"),
socialLinks: z.object({
github: z.string().url("Invalid URL").optional().or(z.literal("")),
twitter: z.string().url("Invalid URL").optional().or(z.literal("")),
linkedin: z.string().url("Invalid URL").optional().or(z.literal("")),
}),
});
export type ProfileData = z.infer<typeof profileSchema>;// components/ProfileForm.tsx (skills section)
"use client";
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { profileSchema, type ProfileData } from "@/schemas/profile";
export function ProfileForm() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<ProfileData>({
resolver: zodResolver(profileSchema),
defaultValues: {
displayName: "",
skills: [{ name: "", level: "beginner", years: 0 }],
socialLinks: { github: "", twitter: "", linkedin: "" },
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "skills",
});
async function onSubmit(data: ProfileData) {
const response = await fetch("/api/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
// Handle response...
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Display Name */}
<div>
<label className="block text-sm font-medium text-gray-300">
Display Name
</label>
<input
{...register("displayName")}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-white"
/>
{errors.displayName && (
<p className="mt-1 text-sm text-red-400">{errors.displayName.message}</p>
)}
</div>
{/* Dynamic Skills */}
<div>
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-300">Skills</label>
<button
type="button"
onClick={() => append({ name: "", level: "beginner", years: 0 })}
className="text-sm text-blue-400 hover:text-blue-300"
>
+ Add Skill
</button>
</div>
{errors.skills?.root && (
<p className="mt-1 text-sm text-red-400">{errors.skills.root.message}</p>
)}
{fields.map((field, index) => (
<div key={field.id} className="mt-3 flex gap-3">
<input
{...register(`skills.${index}.name`)}
placeholder="Skill name"
className="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-white"
/>
<select
{...register(`skills.${index}.level`)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-white"
>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</select>
<input
type="number"
{...register(`skills.${index}.years`, { valueAsNumber: true })}
placeholder="Years"
className="w-20 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-white"
/>
{fields.length > 1 && (
<button
type="button"
onClick={() => remove(index)}
className="text-red-400 hover:text-red-300"
>
Remove
</button>
)}
</div>
))}
</div>
{/* Social Links (nested object) */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">Social Links</label>
<input
{...register("socialLinks.github")}
placeholder="GitHub URL"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-white"
/>
{errors.socialLinks?.github && (
<p className="text-sm text-red-400">{errors.socialLinks.github.message}</p>
)}
<input
{...register("socialLinks.twitter")}
placeholder="Twitter URL"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-white"
/>
<input
{...register("socialLinks.linkedin")}
placeholder="LinkedIn URL"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-white"
/>
</div>
<button
type="submit"
className="w-full rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
>
Save Profile
</button>
</form>
);
}The useFieldArray hook from React Hook Form manages the dynamic list. Each skill entry gets its own set of registered fields using template literal paths like skills.${index}.name. Zod validates the entire array, including the minimum and maximum length constraints.
Step 5: Create a Reusable Form Field Component
To reduce boilerplate, extract a generic form field component.
// components/ui/FormField.tsx
"use client";
import type { FieldError } from "react-hook-form";
import type { InputHTMLAttributes } from "react";
interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: FieldError;
registration: object; // from register()
}
export function FormField({ label, error, registration, ...props }: FormFieldProps) {
return (
<div>
<label className="block text-sm font-medium text-gray-300">{label}</label>
<input
{...registration}
{...props}
className={`mt-1 w-full rounded-lg border px-4 py-2 text-white bg-gray-800 ${
error ? "border-red-500" : "border-gray-700"
}`}
/>
{error && <p className="mt-1 text-sm text-red-400">{error.message}</p>}
</div>
);
}Now your form fields become one-liners:
<FormField
label="Email"
type="email"
error={errors.email}
registration={register("email")}
/>This pattern keeps your form components clean and consistent while the Zod schema remains the authoritative source for validation rules.
Summary
The key insight of this architecture is the shared schema pattern. By defining validation rules in a single Zod schema and importing it on both the client and server, you eliminate an entire category of bugs where client and server validation rules drift apart. React Hook Form provides the performance-optimized form state management, and @hookform/resolvers bridges the two libraries seamlessly.
Next Steps
- ›Add async validation for uniqueness checks (email already exists) using Zod's
.refine()with async functions - ›Build a form builder that generates forms from Zod schemas dynamically
- ›Implement multi-step forms using React Hook Form's
useFormContextwith a shared schema split across steps - ›Add server actions in Next.js 14+ as an alternative to API routes, still validated with the same Zod schema
- ›Integrate with tRPC where Zod schemas serve as both validation and API contract
FAQ
Why use Zod with React Hook Form instead of Yup?
Zod is TypeScript-first and infers types directly from schemas using z.infer, eliminating the need to define separate TypeScript interfaces. Yup works well but requires you to maintain types alongside schemas. Zod's API is also more composable when building complex validations with unions, intersections, and discriminated unions.
Can I use the same Zod schema on the server and client?
Yes, and that is the primary advantage of this approach. Define your schema in a shared file like schemas/registration.ts, import it in your React component for client-side validation via zodResolver, and import the same schema in your API route for server-side validation via .parse(). One source of truth for all validation rules.
How do I handle async validation like checking if a username is taken?
Zod supports async refinements with .refine(async (val) => { ... }). When used with React Hook Form, set the mode option to "onBlur" so async checks run when the user leaves a field rather than on every keystroke. This prevents excessive API calls while still providing timely feedback.
Does React Hook Form re-render the entire form on every change?
No. React Hook Form uses uncontrolled components internally, which means individual field changes do not trigger a full form re-render. Only the specific field that changed and any dependent error messages update. This is one of its key performance advantages over fully controlled form libraries like Formik.
How do I validate dynamic field arrays with Zod?
Use z.array() in your Zod schema combined with the useFieldArray hook from React Hook Form. The schema validates the entire array structure including constraints like .min(1) and .max(10), while useFieldArray handles the UI operations of adding, removing, and reordering items within the form.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Add Observability to a Node.js App with OpenTelemetry
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
How I Structure CI/CD for Next.js, Docker, and GitHub Actions
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.