Form Validation in Next.js with Zod: Client to Server
By Arash Latifi
Practical guide to validating Next.js forms with Zod and React Hook Form — one shared schema for client and Server Actions, clean errors and solid UX.
TL;DR: Make one
Zodschema the source of truth and reuse it withReact Hook Formon the client and inServer Actionson the server. Fast feedback, identical errors and real security.
Why Zod for Next.js forms
Client-only validation is insecure and server-only feels slow. A single Zod schema gives you types and runs everywhere. It pairs with Server Actions and useActionState and keeps messages consistent.
For boundaries see RSC poisoning and use-client boundaries and caching and PPR in Next.js 15.
One shared schema
Create schemas/contact.ts and import it everywhere.
// 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(50),
email: z.string().email("Enter a valid email").max(100),
phone: z.string().regex(/^09\d{9}$/, "Invalid phone").optional().or(z.literal("")),
message: z.string().min(10, "Message must be at least 10 characters").max(1000),
budget: z.enum(["low", "mid", "high"], { message: "Pick a budget" }),
});
export type ContactInput = z.infer<typeof contactSchema>;
One schema, one place to change rules, types via z.infer.
Client: React Hook Form + zodResolver
// app/contact/ContactForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, type ContactInput } from "@/schemas/contact";
export function ContactForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<ContactInput>({
resolver: zodResolver(contactSchema),
defaultValues: { name: "", email: "", phone: "", message: "", budget: "mid" },
mode: "onBlur",
});
const onSubmit = async (data: ContactInput) => {
const res = await fetch("/api/contact", { method: "POST", body: JSON.stringify(data) });
if (!res.ok) alert("Failed to send");
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<input {...register("name")} placeholder="Name" aria-invalid={!!errors.name} />
{errors.name && <p className="text-sm text-red-600">{errors.name.message}</p>}
<input {...register("email")} placeholder="Email" />
{errors.email && <p className="text-sm text-red-600">{errors.email.message}</p>}
<textarea {...register("message")} placeholder="Message" />
{errors.message && <p className="text-sm text-red-600">{errors.message.message}</p>}
<button disabled={isSubmitting} className="rounded bg-blue-600 px-4 py-2 text-white">
{isSubmitting ? "Sending..." : "Send"}
</button>
</form>
);
}
Use mode: "onBlur" to reduce noise and disable the button while submitting.
Server: validation with Server Actions
Never trust the client. Re-run the same schema on the server.
Works well with Persian RTL optimization in Next.js 15.
Tool comparison
| Tool | Strengths | Best for | Note |
|---|---|---|---|
| Zod + React Hook Form | Strong types, shared schema, huge ecosystem | Common to complex forms | Don't forget zodResolver |
| Valibot | Smaller bundle, faster | Bundle-sensitive apps | Different API |
| Yup | Mature | Legacy migration | Weaker types |
For most Next.js forms Zod wins on docs and ecosystem.
Implementation checklist
- [ ] Schema in one shared file, imported on client and server
- [ ] Error messages short and identical on both sides
- [ ] Inputs have
maxlimits and trim whitespace - [ ]
aria-invalidset for accessibility - [ ] Submit button disabled during
isSubmittingandpending - [ ] Server uses
safeParseand returnsfieldErrors - [ ] Manual tests with empty and invalid inputs pass
- [ ] Server logs failures without leaking sensitive data
FAQ
Q: Is client validation enough?
No. Always re-run the same schema on the server.
Q: Where should I keep the Zod schema?
In schemas/ and reuse the single contactSchema export everywhere.
Q: How do I validate file uploads?
Use z.instanceof(File).refine(f => f.size < 2_000_000, "File too large") and repeat checks on the server.
Sources
- Official Zod documentation — schemas,
safeParse, and custom error messages - React Hook Form quickstart — the
useFormandhandleSubmitpattern used above - Server Functions guide in the Next.js docs — where server-side validation runs
- Zod v4 with forms and Server Actions in Next.js — an independent, current walkthrough
- Resolvers issue about conditional fields — community experience with resolver boundaries