Client validation is a UX nicety. Server validation is the security boundary. Anyone can POST to the action endpoint; you can never trust the client to have done the check.
Zod fits cleanly
Define a schema, call safeParse on the form input, branch on success/failure. On failure, return field-level errors via useActionState; on success, run the mutation and revalidate.
Type the state shape
Make the action's State type explicit. Front-end and back-end agree on the shape via TypeScript.
Use Zod first to convert transport strings into domain-shaped input, then enforce stateful rules with the database. Missing values, formats, and ranges fit the schema; uniqueness, inventory, and concurrent ownership belong in constraints and transactions. A successful schema parse is not a safe write. Data can change between validation and mutation. Likewise, a database error is a poor field message. Let each layer protect the invariant it can actually see.
POST missing, malformed, oversized, and duplicate values directly to the action. Field errors should connect to labels and focus, no failing case should mutate or invalidate, and concurrent writes should still be stopped by database guarantees.
Code
Server Action with Zod validation·ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
const CreatePost = z.object({
title: z.string().min(1, 'Title is required').max(100),
content: z.string().min(10, 'Content must be at least 10 characters'),
email: z.string().email('Invalid email address'),
});
export type State = {
errors?: { title?: string[]; content?: string[]; email?: string[] };
message?: string;
};
export async function createPost(prev: State, formData: FormData): Promise<State> {
const parsed = CreatePost.safeParse({
title: formData.get('title'),
content: formData.get('content'),
email: formData.get('email'),
});
if (!parsed.success) {
return {
errors: parsed.error.flatten().fieldErrors,
message: 'Validation failed',
};
}
await db.post.create({ data: parsed.data });
revalidatePath('/posts');
return { message: 'Post created' };
}
Add Zod validation to one of your existing Server Actions. Render at least three field-level errors via useActionState. Submit invalid input and confirm the action never touches the database.
Progress
Progress is local-only — sign in to sync across devices.