Next.js
Validate Next.js Server Actions with Zod
Parse FormData with Zod on the server, return field errors, and keep invalid input out of the database.
- Zod
- TypeScript
- Server Actions
- Validation
On this page
FormData is a bag of strings, files, and surprises. A checkbox arrives as "on" or is missing. A number input arrives as text. A hidden field arrives as whatever the browser, or a script, decided to send. TypeScript does not see any of that, because the type of FormData is already FormData. The check has to happen at runtime, on the server, before you insert a row or call a provider.
Zod is a small way to write that check once. You describe the object you wish you had received. safeParse tells you whether the real input matches. On failure you map issues to field names and return them to the form. On success the parsed value is typed, trimmed, and safe to pass to the database function. Client-side checks can stay for instant feedback. They are not this check.
Describe the payload, not the form
Name fields the way the rest of the app names them, not the way a particular input is labeled. Coerce numbers only when you mean to. Reject unknown keys if the action should not accept an extra role or price the form does not show. Keep the schema next to the action so a new field has to update both.
import { z } from "zod";
export const projectSchema = z.object({
name: z.string().trim().min(2).max(80),
summary: z.string().trim().min(20).max(280),
budget: z.coerce.number().int().positive().max(1_000_000),
});
export type ProjectInput = z.infer<typeof projectSchema>;
Parse on the server
Object.fromEntries is convenient and slightly too trusting, because it keeps the last value when a name is repeated. For this kind of form that is usually what you want. If a field must be a single file, read it with formData.get and validate it separately. Do not pass the raw FormData object into a database client.
"use server";
import { projectSchema } from "@/lib/project-schema";
export async function createProject(_previous: unknown, formData: FormData) {
const parsed = projectSchema.safeParse({
name: formData.get("name"),
summary: formData.get("summary"),
budget: formData.get("budget"),
});
if (!parsed.success) {
const fieldErrors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const key = String(issue.path[0] ?? "form");
fieldErrors[key] = issue.message;
}
return { status: "error" as const, fieldErrors };
}
await insertProject(parsed.data);
return { status: "success" as const, fieldErrors: {} };
}
declare function insertProject(input: {
name: string;
summary: string;
budget: number;
}): Promise<void>;
parsed.data is the only value insertProject should accept. If a teammate later adds ownerId to the insert, it should be taken from the session, not added to the schema the browser can satisfy. Validation of shape and authorization are different steps. Zod does not know who is signed in.
Messages a person can fix
- Replace the default "Invalid input" with a sentence that names the limit, such as "Use 2 to 80 characters."
- Map one issue per field. Showing five errors on the same input hides the first fix.
- Return a form-level message when the database rejects a unique name. Zod cannot know that until the insert runs.
- Log the raw issue list on the server if you need it. Do not send stack traces back to the browser.
Share the schema with the client carefully
You can import the same schema in a client component to check before submit. That import must not pull server-only modules along with it. Keep the schema file free of database clients, secrets, and "use server". The action file imports the schema. The form imports the schema. Neither imports the other way around if you can help it.
A client check that drifts from the server check is worse than no client check, because the user fixes the error you showed and then hits a different one. When a rule changes, change the schema and let both sides follow. If a rule is server-only, such as "this email is not already registered," leave it out of the shared schema and return it as a form-level message after the lookup.
What Zod will not do
It will not rate-limit the action. It will not prove the user owns the row they are editing. It will not scan an uploaded file for malware. Put those checks beside the parse, in order: authenticate, authorize, parse, then write. Teams that bury authorization inside a refinement tend to skip it on the next action. A visible function named requireUser() is harder to forget than a schema someone has to remember to extend.
Test the schema with the ugly inputs
A schema that only sees the happy path from the browser will accept a string of spaces, a budget of "1e6", or a second field named budget that fromEntries collapsed. Write a few cases next to the schema: missing name, name of one character, summary of exactly 20 characters, budget of 0, budget of a word, and a number with a currency symbol. safeParse should fail each of them, and the field key in the error map should be the input the user can see. Add one case that passes, and assert the parsed budget is a number rather than a string. That last assertion is the one that catches a refactor that removed z.coerce and started inserting numeric text into an integer column. Keep these cases in the same change as the schema. A validation library you cannot fail on purpose is a comment.
