Acme
Back to Blog

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.

JJohn Doe

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/resolvers

Creating 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:

ModeWhen Validation RunsBest ForPerformance
onSubmitOnly on form submissionSimple forms, fewer fields⚡ Excellent
onBlurWhen user leaves a fieldMost forms, better UX⚡ Good
onChangeOn every keystrokeReal-time validation needed⚠️ Can be slow
onTouchedAfter field is touchedBalance 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 form

This 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

Want to dive deeper? Check out these resources:

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!

Footnotes

  1. We recommend using Sonner for beautiful toast notifications - it's already included in this project!

The magic of AI at your fingertips.

Join thousands of professionals who have already streamlined their scheduling with our AI Assistant.

Building Type-Safe Forms with React Hook Form and Zod | Acme