Skip to main content
Back to Blog
Tutorials
3 min read
November 8, 2024

How to Set Up a Contact Form with React Hook Form and Zod Validation

Build a type-safe contact form using React Hook Form and Zod. Server-side validation, error handling, and email delivery included.

Ryel Banfield

Founder & Lead Developer

Every business website needs a contact form. Here is how to build one that is type-safe, accessible, well-validated, and delivers emails reliably using React Hook Form and Zod in a Next.js application.

Why React Hook Form + Zod

  • React Hook Form: Minimal re-renders, built-in accessibility, low bundle size
  • Zod: Type-safe schema validation that works on both client and server
  • Together: Define your schema once, get TypeScript types and validation for free

Step 1: Install Dependencies

pnpm add react-hook-form zod @hookform/resolvers

Step 2: Define the Schema

// 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 under 100 characters"),
  email: z
    .string()
    .email("Please enter a valid email address"),
  phone: z
    .string()
    .optional()
    .refine(
      (val) => !val || /^\+?[\d\s\-()]+$/.test(val),
      "Please enter a valid phone number"
    ),
  message: z
    .string()
    .min(10, "Message must be at least 10 characters")
    .max(5000, "Message must be under 5000 characters"),
});

export type ContactFormData = z.infer<typeof contactSchema>;

This schema is used for both client-side and server-side validation.

Step 3: Build the Form Component

// components/contact/ContactForm.tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, type ContactFormData } from "@/lib/schemas/contact";
import { useState } from "react";

export function ContactForm() {
  const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");

  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
  });

  async function onSubmit(data: ContactFormData) {
    setStatus("submitting");

    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");
      }

      setStatus("success");
      reset();
    } catch {
      setStatus("error");
    }
  }

  if (status === "success") {
    return (
      <div className="rounded-lg bg-green-50 p-6 text-green-800 dark:bg-green-900/20 dark:text-green-400">
        <p className="font-medium">Message sent successfully.</p>
        <p className="mt-1 text-sm">We will get back to you within 24 hours.</p>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6" noValidate>
      <div>
        <label htmlFor="name" className="block text-sm font-medium">
          Name
        </label>
        <input
          id="name"
          type="text"
          autoComplete="name"
          {...register("name")}
          className="mt-1 block w-full rounded-md border px-3 py-2"
          aria-invalid={errors.name ? "true" : "false"}
          aria-describedby={errors.name ? "name-error" : undefined}
        />
        {errors.name && (
          <p id="name-error" className="mt-1 text-sm text-red-600" role="alert">
            {errors.name.message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          type="email"
          autoComplete="email"
          {...register("email")}
          className="mt-1 block w-full rounded-md border px-3 py-2"
          aria-invalid={errors.email ? "true" : "false"}
          aria-describedby={errors.email ? "email-error" : undefined}
        />
        {errors.email && (
          <p id="email-error" className="mt-1 text-sm text-red-600" role="alert">
            {errors.email.message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="phone" className="block text-sm font-medium">
          Phone (optional)
        </label>
        <input
          id="phone"
          type="tel"
          autoComplete="tel"
          {...register("phone")}
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
        {errors.phone && (
          <p className="mt-1 text-sm text-red-600" role="alert">
            {errors.phone.message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium">
          Message
        </label>
        <textarea
          id="message"
          rows={5}
          {...register("message")}
          className="mt-1 block w-full rounded-md border px-3 py-2"
          aria-invalid={errors.message ? "true" : "false"}
          aria-describedby={errors.message ? "message-error" : undefined}
        />
        {errors.message && (
          <p id="message-error" className="mt-1 text-sm text-red-600" role="alert">
            {errors.message.message}
          </p>
        )}
      </div>

      {status === "error" && (
        <p className="text-sm text-red-600" role="alert">
          Something went wrong. Please try again.
        </p>
      )}

      <button
        type="submit"
        disabled={status === "submitting"}
        className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
      >
        {status === "submitting" ? "Sending..." : "Send Message"}
      </button>
    </form>
  );
}

Step 4: Create the API Route

// 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();

    // Server-side validation with the same schema
    const result = contactSchema.safeParse(body);

    if (!result.success) {
      return NextResponse.json(
        { error: "Invalid form data", details: result.error.flatten() },
        { status: 400 }
      );
    }

    const { name, email, phone, message } = result.data;

    // Send email (example with Resend)
    // await resend.emails.send({
    //   from: "contact@yourdomain.com",
    //   to: "you@yourdomain.com",
    //   subject: `Contact form: ${name}`,
    //   text: `Name: ${name}\nEmail: ${email}\nPhone: ${phone || 'N/A'}\n\n${message}`,
    // });

    return NextResponse.json({ success: true });
  } catch {
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}

Step 5: Add Rate Limiting

Protect your endpoint from abuse:

import { headers } from "next/headers";

const rateLimit = new Map<string, { count: number; lastReset: number }>();

function checkRateLimit(ip: string): boolean {
  const now = Date.now();
  const entry = rateLimit.get(ip);

  if (!entry || now - entry.lastReset > 60000) {
    rateLimit.set(ip, { count: 1, lastReset: now });
    return true;
  }

  if (entry.count >= 5) {
    return false; // 5 requests per minute max
  }

  entry.count++;
  return true;
}

Accessibility Checklist

  • All inputs have associated labels
  • Error messages use role="alert" for screen reader announcements
  • aria-invalid set on fields with errors
  • aria-describedby links fields to error messages
  • noValidate on form to use custom validation
  • autoComplete attributes for autofill
  • Submit button has clear text indicating state

Next Steps

  • Add honeypot field for bot protection
  • Integrate with an email service (Resend, SendGrid, AWS SES)
  • Add success animations
  • Store submissions in a database for backup
  • Add file upload support

Need a Custom Form?

We build contact forms, multi-step wizards, and complex form workflows for businesses. Get in touch to discuss your requirements.

React Hook FormZodformsvalidationtutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles