How to generate zod validation schemas for next.js server actions
- Step 1Build the parsed payload as JSON — Server Actions receive
FormData; you typically doObject.fromEntries(formData)then parse. Write a JSON sample of that parsed object:{ "title": "Hi", "price": 9.99, "published": true }. Remember every FormData value is a string at runtime, even if you write a number here. - Step 2Drop the JSON onto the tool — Drag a
.jsonfile onto the dropzone above. Spreadsheet inputs (.xlsx,.xls,.ods) are read as a JSON array of row objects if your sample data lives in a sheet. - Step 3Name the schema after the action — Set Root schema name to
createPostSchema. The export becomesexport const createPostSchema = z.object({...})with typeCreatePostSchema. - Step 4Pick Strict objects if the DB write must be exact — Tick Strict objects to emit
z.strictObjectso any field beyond your declared columns is rejected before it can reach the database layer. - Step 5Generate, then copy or download — Click Generate Schema and Copy or Download .ts. The output begins with
import { z } from "zod";. - Step 6Adapt for FormData and add constraints — Switch string-sourced numbers/booleans to coercion:
price: z.coerce.number(),published: z.coerce.boolean(). Add format rules (.email(),.min(1)). Then in the action:const r = schema.safeParse(Object.fromEntries(formData)); if (!r.success) return { errors: r.error.flatten().fieldErrors };.
FormData reality vs generated type
The crucial gotcha for Server Actions: FormData delivers everything as strings, so the inferred numeric/boolean types need coercion.
| Sample field | Generated | FormData runtime | Use instead |
|---|---|---|---|
9.99 | z.number() | '9.99' (string) | z.coerce.number() |
30 | z.number().int() | '30' (string) | z.coerce.number().int() |
true | z.boolean() | 'on' / 'true' | z.coerce.boolean() or custom |
"hello" | z.string() | 'hello' | fine as-is, add .min(1) |
"a@b.com" | z.string() | 'a@b.com' | add .email() |
Options exposed in the UI
The controls above the Generate button and how they shape the action's input schema.
| Control | Default | Effect |
|---|---|---|
| Root schema name | schema | Names the exported const and type (capitalised) |
| Export schemas | On | Prefixes const/type with export |
| Strict objects | Off | Uses z.strictObject to reject undeclared fields |
Cookbook
Sample payloads, the generated schema, and the Server Action validation block you build around it.
The classic Server Action validate-then-write
ExampleGenerate the skeleton, coerce FormData-string fields, then safeParse before the DB write.
Sample parsed payload:
{ "title": "Hello", "price": 9.99, "published": true }
Generated:
import { z } from "zod";
export const createPostSchema = z.object({
title: z.string(),
price: z.number(),
published: z.boolean()
});
Action (you add coercion):
'use server';
export async function createPost(_: unknown, formData: FormData) {
const r = createPostSchema.extend({
price: z.coerce.number(),
published: z.coerce.boolean()
}).safeParse(Object.fromEntries(formData));
if (!r.success) return { errors: r.error.flatten().fieldErrors };
await db.post.create({ data: r.data });
return { ok: true };
}useActionState wiring
ExampleThe generated z.infer type names the action's input; the flattened fieldErrors map straight onto your form's error display.
export type CreatePostSchema = z.infer<typeof createPostSchema>;
'use client';
const [state, action] = useActionState(createPost, { errors: {} });
<form action={action}>
<input name="title" />
{state.errors?.title?.[0]}
<input name="price" />
{state.errors?.price?.[0]}
</form>Nested payload from a structured form
ExampleIf you build a nested object before parsing (e.g. grouping address fields), the schema mirrors it.
Sample:
{ "name": "Sam", "address": { "city": "NYC", "zip": "10001" } }
Generated:
export const schema = z.object({
name: z.string(),
address: z.object({
city: z.string(),
zip: z.string()
})
});
// Note: FormData is flat — you must assemble the nested
// object yourself before parsing.Strict to keep stray inputs out of the DB
ExampleTurn on Strict objects so an extra hidden field or stale input can't slip into the mutation payload.
Strict objects: ON
export const schema = z.strictObject({
title: z.string(),
published: z.boolean()
});
safeParse({ title, published, _csrf })
// → success: false, 'Unrecognized key(s): _csrf'
// The DB write never receives _csrf.Checkbox boolean from FormData
ExampleUnchecked checkboxes are simply absent from FormData, and checked ones send 'on'. The generated z.boolean() won't handle either — preprocess it.
Generated:
active: z.boolean()
Reality: formData has no 'active' key when unchecked,
and 'on' when checked. Replace with:
active: z.preprocess(v => v === 'on' || v === 'true' || v === true,
z.boolean())
// now absent → false, 'on' → trueErrors and edge cases
Real errors and silent failures sourced from each platform's own documentation. Match the wording to the row, fix what the row says to fix.
Numbers fail because FormData sends strings
String at runtimeA sample { price: 9.99 } infers z.number(), but formData.get('price') is '9.99', so safeParse(Object.fromEntries(formData)) fails with 'Expected number, received string'. Switch those fields to z.coerce.number(). This is the single most common Server Action validation bug.
Unchecked checkbox is absent, not false
Missing keyHTML checkboxes send 'on' when checked and nothing at all when unchecked. A generated z.boolean() fails both ways. Use z.preprocess(v => v === 'on', z.boolean()) or .optional().transform(Boolean) so absence becomes false.
Email/format fields are plain strings
By designInference reads runtime types, so emails, URLs, and dates are z.string(). Add .email(), .url(), or .datetime() after generating. The structural skeleton is intentionally constraint-free.
Nested object expected but FormData is flat
Shape mismatchIf your sample is nested (address.city) but you parse raw FormData, the keys are flat ('address.city' or 'city'). Either assemble the nested object before parsing, or flatten the schema. The generator mirrors the sample you give it, not the wire format.
Field was null in the sample
Narrowed typeA null value yields z.null(), accepting only null. For a column that is sometimes a value, rewrite to z.string().nullable(). FormData never produces actual null, so reconsider whether the field should be .optional() instead.
Multi-value field (multiple selects / file inputs)
Array vs singleA <select multiple> or repeated input produces multiple FormData entries; Object.fromEntries keeps only the last. Use formData.getAll('key') to get the array, and model it as z.array(z.string()). A single-value sample will infer the wrong type here.
Strict object rejects the action's hidden fields
By designWith Strict on, z.strictObject rejects keys you didn't declare — including framework or CSRF hidden inputs you forgot to add. This is the intended safety behaviour; either add those keys to the schema or strip them before parsing.
Large fixture array over the row cap
Sliced inputIf you paste an array of payloads larger than your tier's row cap (100 on free, 1,000 on Pro), it is sliced to the first N before inference. For a single action's shape, paste one representative object — extra rows don't enrich the schema.
Frequently asked questions
Why does validation fail with 'Expected number, received string'?
Because FormData delivers every value as a string. A generated z.number() rejects '9.99'. Replace those fields with z.coerce.number() (and z.coerce.boolean() for flags). Generate the schema for the field names, then coerce the string-sourced ones.
How do I handle a checkbox in a Server Action schema?
Unchecked checkboxes are absent from FormData; checked ones send 'on'. A plain z.boolean() fails both. Use z.preprocess(v => v === 'on', z.boolean()) so absence becomes false and 'on' becomes true.
How do I return field errors to the form?
Call schema.safeParse(...); on failure return result.error.flatten().fieldErrors. With useActionState, render state.errors?.<field>?.[0] next to each input. On success, result.data is your typed payload for the DB write.
Does the generated schema add .email() to email fields?
No. Any string value is z.string(). Add .email(), .url(), or .min(1) yourself. The generator produces structure only, leaving validation policy to you.
Should I turn on Strict objects for a Server Action?
Often yes, since the parsed payload feeds a DB write — z.strictObject ensures only declared columns get through and an injected field is rejected. Just remember to declare any legitimate hidden inputs, or strip them before parsing.
My form has nested fields — does FormData support that?
FormData is flat. If your generated schema is nested (because your sample was), you must assemble the nested object from flat keys before parsing, or flatten the schema. The generator mirrors the JSON you provide, not the wire format.
How do I handle a multiple-select or repeated field?
Use formData.getAll('key') to collect all values into an array, and model the field as z.array(z.string()). Object.fromEntries keeps only the last value, so a single-value sample will mislead inference — build the array explicitly.
Is my mutation payload uploaded for generation?
No. Schema generation is entirely in-browser. Even sensitive payload samples never reach JAD Apps. Strip secrets from the sample before pasting; only the shape is needed.
Can I reuse the generated schema as my action's input type?
Yes. The exported z.infer<typeof schema> type narrows result.data after a successful safeParse, so the DB call is type-checked. Rename the root to match your action (e.g. createPostSchema → CreatePostSchema).
How do I add an optional field that wasn't in the sample?
Add .optional() (or .default(...)) after generating. The generator only sees keys present in the sample and treats them all as required. For FormData, prefer .optional() since absent inputs are common.
Can I generate from a row of test data in a spreadsheet?
Yes — drop an .xlsx, .xls, or .ods file. The first sheet is read as a JSON array of row objects. You'll get z.array(z.object({...})); lift the inner object for a single action's schema.
I only need a TypeScript type, not runtime validation — what then?
Use json-to-typescript for a plain interface. But for a Server Action that writes to a database, you almost always want runtime validation, which is exactly what Zod provides — so generating a Zod schema here is the safer choice.
Privacy first
Conversion runs locally in your browser. No file is uploaded — only metadata counters are saved for signed-in dashboard stats.