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-invalidset on fields with errors -
aria-describedbylinks fields to error messages -
noValidateon form to use custom validation -
autoCompleteattributes 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.