Building Type-Safe Forms with React Hook Form and Zod
Learn how to create fully type-safe, validated forms using React Hook Form and Zod in Next.js applications.
Building Type-Safe Forms with React Hook Form and Zod
Forms are the backbone of user interaction in web applications. In this guide, we'll explore how to build type-safe, validated forms using React Hook Form and Zod in a Next.js application.
Why React Hook Form + Zod?
The combination of React Hook Form and Zod provides several key benefits:
- Type Safety - End-to-end TypeScript support from schema to form data
- Performance - Minimal re-renders with uncontrolled components
- Developer Experience - Clean API with excellent TypeScript inference
- Runtime Validation - Catch invalid data before it reaches your API
Basic Setup
First, let's install the required dependencies:
pnpm add react-hook-form zod @hookform/resolversCreating a Simple Form
Let's start with a basic contact form. First, we define our schema with Zod:
import { z } from "zod";
const contactFormSchema = z.object({
firstName: z.string().min(2, "First name must be at least 2 characters"),
lastName: z.string().min(2, "Last name must be at least 2 characters"),
email: z.string().email("Please enter a valid email address"),
company: z.string().optional(),
message: z.string().min(10, "Message must be at least 10 characters"),
});
// Infer TypeScript type from the schema
type ContactFormData = z.infer<typeof contactFormSchema>;The beauty of z.infer is that your TypeScript types are automatically derived from your validation schema - single source of truth!
Building the Form Component
Now let's create our form using React Hook Form with the Zod resolver:
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<ContactFormData>({
resolver: zodResolver(contactFormSchema),
});
const onSubmit = async (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");
}
// Success! Reset the form
reset();
alert("Message sent successfully!");
} catch (error) {
console.error("Error:", error);
alert("Failed to send message. Please try again.");
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Input
{...register("firstName")}
placeholder="First Name"
aria-invalid={errors.firstName ? "true" : "false"}
/>
{errors.firstName && (
<p className="mt-1 text-sm text-red-600">
{errors.firstName.message}
</p>
)}
</div>
<div>
<Input
{...register("lastName")}
placeholder="Last Name"
aria-invalid={errors.lastName ? "true" : "false"}
/>
{errors.lastName && (
<p className="mt-1 text-sm text-red-600">
{errors.lastName.message}
</p>
)}
</div>
</div>
<div>
<Input
{...register("email")}
type="email"
placeholder="Email"
aria-invalid={errors.email ? "true" : "false"}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">
{errors.email.message}
</p>
)}
</div>
<div>
<Input
{...register("company")}
placeholder="Company (optional)"
/>
</div>
<div>
<Textarea
{...register("message")}
placeholder="Your message"
rows={5}
aria-invalid={errors.message ? "true" : "false"}
/>
{errors.message && (
<p className="mt-1 text-sm text-red-600">
{errors.message.message}
</p>
)}
</div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send Message"}
</Button>
</form>
);
}Server-Side Validation
Don't forget to validate on the server side too! Here's how to handle the API route:
// app/api/contact/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
// Reuse the same schema!
const contactFormSchema = z.object({
firstName: z.string().min(2),
lastName: z.string().min(2),
email: z.string().email(),
company: z.string().optional(),
message: z.string().min(10),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate the request body
const validatedData = contactFormSchema.parse(body);
// Process the validated data
// await sendEmail(validatedData);
// await saveToDatabase(validatedData);
return NextResponse.json({ message: "Success" }, { status: 200 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ errors: error.errors }, { status: 400 });
}
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}Advanced: Custom Validation Rules
Zod allows you to create custom validation rules. Here's an example with a password confirmation:
const signUpSchema = z
.object({
email: z.string().email("Invalid email address"),
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"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"], // Error appears on confirmPassword field
});Pro Tips
Here are some advanced patterns to make your forms even better:
1. Field-Level Revalidation
const { register } = useForm<ContactFormData>({
resolver: zodResolver(contactFormSchema),
mode: "onBlur", // Validate on blur
reValidateMode: "onChange", // Revalidate on change after first submit
});2. Default Values
const { register } = useForm<ContactFormData>({
resolver: zodResolver(contactFormSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
company: "",
message: "",
},
});3. Transform Data Before Submission
const emailSchema = z.object({
email: z
.string()
.email()
.transform((val) => val.toLowerCase().trim()),
});Validation Modes Comparison
Choosing the right validation mode impacts user experience. Here's a comparison:
| Mode | When Validation Runs | Best For | Performance |
|---|---|---|---|
onSubmit | Only on form submission | Simple forms, fewer fields | ⚡ Excellent |
onBlur | When user leaves a field | Most forms, better UX | ⚡ Good |
onChange | On every keystroke | Real-time validation needed | ⚠️ Can be slow |
onTouched | After field is touched | Balance between UX and performance | ⚡ Good |
onChange mode was commonly recommended in the past, but onBlur provides better UX without overwhelming users with error messages while typing.
Form UI Components with shadcn/ui
For production-ready forms, consider using shadcn/ui's Form components which integrate seamlessly with React Hook Form:
npx shadcn@latest add formThis provides pre-built components with proper accessibility, error handling, and styling.
Form Development Checklist
Use this checklist to ensure your forms are production-ready:
- Define Zod schema with proper validation rules
- Set up React Hook Form with zodResolver
- Add error messages for all fields
- Implement loading states during submission
- Add success/error toast notifications1
- Test with invalid data
- Add ARIA labels for accessibility
- Test keyboard navigation
- Implement auto-save (if applicable)
Conclusion
React Hook Form combined with Zod provides a powerful, type-safe solution for form handling in modern React applications. Key takeaways:
- ✅ Type safety from schema to submission
- ✅ Runtime validation catches errors early
- ✅ Great DX with TypeScript inference
- ✅ Performance with minimal re-renders
- ✅ Reusable schemas across client and server
Related Reading
Want to dive deeper? Check out these resources:
- React Hook Form Documentation - Complete API reference and guides
- Zod Documentation - Learn all validation methods
- shadcn/ui Form Component - Pre-built form components
- Web Accessibility Guidelines (WCAG) - Make forms accessible
In the next post, we'll explore how to integrate these forms with Supabase for real-time data synchronization!
Want to learn more about form validation patterns? Let us know what specific use cases you'd like us to cover!