Mastering Zod Validation in Next.js Server Actions - By Sourav Mishra (@souravvmishra)
Learn how to implement type-safe validation in Next.js Server Actions using Zod. A complete guide to handling forms, errors, and schema reuse.
In this guide, I, Sourav Mishra, demonstrate how to build robust, type-safe forms in Next.js using Zod and Server Actions.
Validation is the backbone of secure web applications. Without it, you're trusting users to send perfect data—a guarantee for runtime errors and security vulnerabilities.
Why Zod with Next.js?
Zod is the industry standard for schema validation in modern TypeScript applications. According to the official Zod documentation, it offers:
- Runtime Validation: It checks data structure at runtime (crucial for API inputs).
- Type Inference: You don't write types twice; you derive them from your schema.
- Developer Experience: The API is chainable, readable, and intuitive.
When combined with Next.js Server Actions, Zod prevents invalid data from ever processing on your server, ensuring a "parse, don't validate" approach.
1. Defining the Schema
First, define your schema. This should live in a shared file so both your client (form) and server (action) can access it.
// lib/schemas.ts
import { z } from "zod";
export const contactSchema = z.object({
email: z.string().email({ message: "Please enter a valid email." }),
message: z.string().min(10, { message: "Message must be at least 10 characters." }),
type: z.enum(["support", "feedback", "general"]),
});
export type ContactFormValues = z.infer<typeof contactSchema>;
2. The Server Action
In your Server Action, use safeParse to validate the raw form data. This method doesn't throw errors; it returns a success or error object you can handle gracefully.
// app/actions.ts
"use server";
import { contactSchema } from "@/lib/schemas";
export type ActionState = {
success?: boolean;
message?: string;
errors?: {
[K in keyof typeof contactSchema.shape]?: string[];
};
};
export async function submitContactForm(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// Extract data from FormData
const data = {
email: formData.get("email"),
message: formData.get("message"),
type: formData.get("type"),
};
// Validate
const parsed = contactSchema.safeParse(data);
if (!parsed.success) {
// Return validation errors to the client
return {
success: false,
message: "Validation failed",
errors: parsed.error.flatten().fieldErrors,
};
}
// Simulate database operation
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("Saving to DB:", parsed.data);
return {
success: true,
message: "Message sent successfully!",
};
}
3. The Client Component
Displaying validation errors is critical for UX. We use the useActionState (formerly useFormState) hook to manage the form lifecycle.
// components/ContactForm.tsx
"use client";
import { useActionState } from "react";
import { submitContactForm } from "@/app/actions";
export function ContactForm() {
const [state, action, isPending] = useActionState(submitContactForm, {});
return (
<form action={action} className="space-y-4 max-w-md mx-auto">
{state.success && (
<div className="p-4 bg-green-100 text-green-700 rounded-md">
{state.message}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input name="email" id="email" className="border p-2 w-full rounded" />
{state.errors?.email && (
<p className="text-red-500 text-sm">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium">Message</label>
<textarea name="message" id="message" className="border p-2 w-full rounded" />
{state.errors?.message && (
<p className="text-red-500 text-sm">{state.errors.message[0]}</p>
)}
</div>
{/* Hidden input for simplicity in this example */}
<input type="hidden" name="type" value="general" />
<button
type="submit"
disabled={isPending}
className="bg-black text-white px-4 py-2 rounded disabled:opacity-50"
>
{isPending ? "Sending..." : "Send Message"}
</button>
</form>
);
}
Advanced: Zod Refinements
Sometimes a field depends on another. Zod's superRefine or refine is perfect for this.
const passwordSchema = z.object({
password: z.string(),
confirm: z.string(),
}).refine((data) => data.password === data.confirm, {
message: "Passwords don't match",
path: ["confirm"], // Error attaches to the 'confirm' field
});
See my guide on Typescript Generics for more on advanced typing.
Conclusion
Zod and Next.js Server Actions are a perfect match. You get end-to-end type safety, a great developer experience, and a secure backend.
If you're looking to upgrade your styling workflow as well, check out my article on Why Use Shadcn.
FAQ
Q: Can I use Zod with client-side forms?
Yes. You can use libraries like react-hook-form with a Zod resolver for instant client-side feedback before sending data to the server.
Q: Is safeParse better than parse?
For Server Actions, yes. parse throws an error which crashes the request unless caught. safeParse returns a predictable object, making error handling smoother.
Q: How do I handle file uploads?
Zod has limited built-in support for File in the browser, but on the server, you often need to validate the file type and size manually or use specific custom validators.
This guide was written by Sourav Mishra, a Full Stack Engineer specializing in Next.js and secure web architecture.