Type-Safe Server Actions in Next.js App Router with Zod Validation
Why Server Actions Need Validation
Next.js Server Actions run on the server and can directly write to your database. Without input validation, malicious or malformed form submissions can corrupt your data or trigger unhandled exceptions. Zod gives you a schema-first approach: define the expected shape once, and get automatic runtime validation plus TypeScript inference for free.
Setting Up a Validated Server Action
"use server";
import { z } from "zod";
import { createAdminClient } from "@/utils/supabase/server";
import { revalidatePath } from "next/cache";
// Define schema once — TypeScript type is inferred automatically
const CreateProjectSchema = z.object({
name: z.string().min(2).max(120),
slug: z.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
tagline: z.string().min(10).max(240),
category: z.enum(["IoT", "AI", "Web", "Android", "Embedded", "Other"]),
featured: z.coerce.boolean().default(false),
});
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export async function createProject(formData: FormData) {
// 1. Validate input — throws if invalid
const parsed = CreateProjectSchema.safeParse({
name: formData.get("name"),
slug: formData.get("slug"),
tagline: formData.get("tagline"),
category: formData.get("category"),
featured: formData.get("featured"),
});
if (!parsed.success) {
// Return structured errors to the client
return { error: parsed.error.flatten().fieldErrors };
}
// 2. Proceed with validated, typed data
const adminClient = createAdminClient();
const { error } = await adminClient
.from("projects")
.insert(parsed.data);
if (error) return { error: { _form: [error.message] } };
revalidatePath("/projects");
return { success: true };
}
Consuming Errors in the Client Component
"use client";
import { useActionState } from "react";
import { createProject } from "./actions";
export function NewProjectForm() {
const [state, action] = useActionState(createProject, null);
return (
<form action={action}>
<input name="name" />
{state?.error?.name && (
<p className="text-red-500">{state.error.name[0]}</p>
)}
<button type="submit">Create</button>
</form>
);
}
Key Patterns
z.coerce: Usez.coerce.boolean()andz.coerce.number()for FormData values, which are always strings.safeParsevsparse:safeParsereturns a result object instead of throwing, which is safer inside Server Actions where exceptions show as opaque 500 errors.flatten().fieldErrors: Returns per-field error arrays that map directly to form field names.
Building a custom Next.js admin portal? Let's talk →
Frequently Asked Questions
Q:Can I reuse Zod schemas between server and client components?
Yes. Define schemas in a shared file (e.g. lib/schemas.ts) and import them in both Server Actions and client-side React Hook Form configurations.
Q:How do I handle file uploads in Server Actions with Zod?
Zod does not validate File objects natively. Use z.instanceof(File) or check file.size and file.type manually after extracting the file from FormData, then upload to storage separately.
Related Engineering Notes
Related Project Cases
Matching Services Tracks
Working on something similar?
Let's collaborate to design custom PCB schematics, write deterministic FreeRTOS threads, or configure secure Next.js databases.