How to generate a zod schema for api request body validation
- Step 1Grab a representative request body — Copy a real POST/PUT body from your logs, Postman, or your API docs. Make sure every field that can appear is present and populated — inference only sees keys that exist in the sample, and uses the value's type to pick the Zod leaf.
- Step 2Drop the JSON onto the tool — Drag a
.jsonfile onto the dropzone above. Spreadsheets (.xlsx,.xls,.ods) are accepted too and read as a JSON array of row objects — handy if your test fixtures live in a sheet. - Step 3Name the schema after the endpoint — Set the Root schema name to something like
createUserBody. The export becomesexport const createUserBody = z.object({...})and the type alias isCreateUserBody. - Step 4Decide on Strict objects — For a public API, tick Strict objects so
z.strictObjectrejects any field not in the contract. Leave it off if your endpoint tolerates forward-compatible extra keys. - Step 5Generate, then copy or download — Click Generate Schema. Copy the module or Download .ts. It opens with
import { z } from "zod";followed by the exported const and the inferred type. - Step 6Add validators and coercion at the edge — Refine the skeleton:
email: z.string().email(),id: z.string().uuid(),quantity: z.number().int().positive(). For query strings (always strings) usez.coerce.number()/z.coerce.boolean(). Then callschema.safeParse(body)and return a 400 on failure.
Request value to Zod type, and the validator you add
Structural inference output for typical API fields, with the constraint you usually layer on at the validation boundary.
| Sample field | Generated | Add for an API |
|---|---|---|
"3fa85f64-..." id | z.string() | .uuid() |
"a@b.com" | z.string() | .email() |
42 (count/id) | z.number().int() | .int().positive() |
19.95 (price) | z.number() | .nonnegative() |
true | z.boolean() | leave as-is |
{ "meta": {...} } | nested z.object({...}) | constrain inner fields |
["tag1"] | z.array(z.string()) | .min(1).max(20) |
null | z.null() | rewrite to z.string().nullable() |
Tier limits relevant to this tool
Code-generation quotas and input caps by tier. Array inputs over the row cap are sliced to the first N records before inference; non-array JSON has no row cap.
| Tier | Runs per day | Array row cap | Max file |
|---|---|---|---|
| Free | 1 | 100 | 2 MB |
| Pro | 5 | 1,000 | 100 MB |
| Pro + Media | 50 | 10,000 | 500 MB |
| Developer | Unlimited | Unlimited | 5 GB |
Cookbook
Sample request bodies, the schema the generator returns, and the production validation call you wrap around it.
Express body-validation middleware
ExampleGenerate the body schema, then validate inside middleware. safeParse keeps you in control of the error response shape.
Input body sample:
{ "email": "a@b.com", "name": "Sam", "plan": "pro" }
Generated:
import { z } from "zod";
export const createUserBody = z.object({
email: z.string(),
name: z.string(),
plan: z.string()
});
export type CreateUserBody = z.infer<typeof createUserBody>;
Middleware (you add .email()):
app.post('/users', (req, res, next) => {
const r = createUserBody.safeParse(req.body);
if (!r.success) return res.status(400).json(r.error.flatten());
req.body = r.data; next();
});Next.js Route Handler
ExampleIn a Route Handler the body is parsed from the request stream. Use the generated schema to validate and narrow in one step.
Generated schema: createUserBody (as above)
export async function POST(req: Request) {
const body = await req.json();
const parsed = createUserBody.safeParse(body);
if (!parsed.success) {
return Response.json(parsed.error.flatten(), { status: 400 });
}
// parsed.data is typed CreateUserBody
return Response.json({ ok: true });
}Nested + array payload
ExampleInference handles arbitrarily nested objects and typed arrays. An array of identically-shaped objects collapses to a single element schema.
Input:
{ "order": { "id": 9, "items": [ { "sku": "A", "qty": 2 } ] } }
Generated:
export const schema = z.object({
order: z.object({
id: z.number().int(),
items: z.array(z.object({
sku: z.string(),
qty: z.number().int()
}))
})
});Query-param coercion
ExampleQuery strings are always strings, so a sample like { page: 2 } would mislead you. Generate the body schema for the type names, then coerce for query params.
URL: /search?page=2&active=true
req.query is { page: '2', active: 'true' } — all strings.
Generated from sample gives z.number()/z.boolean(),
but for query params switch to coercion by hand:
const Query = z.object({
page: z.coerce.number().int().min(1),
active: z.coerce.boolean()
});Strict contract for a public endpoint
ExampleTurn on Strict objects to reject any field outside the documented contract — a defensive default for inputs you do not control.
Strict objects: ON
export const schema = z.strictObject({
email: z.string(),
plan: z.string()
});
schema.parse({ email, plan, isAdmin: true })
// throws: Unrecognized key(s) in object: 'isAdmin'
// (a privilege-escalation field never reaches your handler)Errors 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.
UUID / email fields are plain strings
By designThe generator reads runtime types, so any string value is z.string(). UUIDs, emails, URLs, and ISO timestamps all come out as z.string(). Add .uuid(), .email(), .url(), or .datetime() at the validation boundary yourself.
Numeric query params look like numbers
String at runtimeIf your sample shows { "page": 2 } the schema gets z.number().int(), but real query strings deliver '2'. Switch those fields to z.coerce.number() for any data sourced from URL query strings or form-encoded bodies.
Optional/absent body field
Missing keyInference only includes keys present in the sample. An optional field omitted from your example will not appear in the schema. Include it in the sample, or add it as .optional() afterwards. For nullable-or-absent, combine .nullable().optional().
null value yields z.null()
Narrowed typeA null in the sample produces z.null(), which accepts only null. For a field that is sometimes a real value, rewrite to e.g. z.string().nullable(). The generator cannot infer the non-null type from a single null.
Array with mixed element types
Union elementIf an array holds different types, e.g. [1, "a"], the element becomes a union: z.array(z.union([z.number().int(), z.string()])). This is faithful to the sample but often a sign your payload is loosely typed — tighten it if the API contract should be homogeneous.
Objects in an array have different shapes
Object unionThe generator does not merge differing object shapes into one schema with optionals. Two array elements with different keys produce z.array(z.union([z.object({...}), z.object({...})])). If you want a single object schema, give a sample where the array elements share one shape.
Empty array element type unknown
Unknown elementAn empty array ([]) becomes z.array(z.unknown()) because the element type cannot be determined. Provide a sample with at least one element, or replace z.unknown() with the real element schema.
Body exceeds tier file size
Size limitFree tier accepts JSON up to 2 MB; Pro up to 100 MB. A request-body sample is tiny, so this rarely bites — but if you feed a giant fixture dump, trim it to one representative record first. The schema does not get richer from more rows.
Frequently asked questions
Does the generator add .email() or .uuid() to my fields?
No. It produces z.string() for any string value. Format constraints like .email(), .uuid(), .url(), and .datetime() express validation policy that cannot be inferred from a sample, so you add them after generating.
How do I use the output in an Express handler?
Import the schema and call schema.safeParse(req.body) in middleware or at the top of the handler. On success === false, return res.status(400).json(result.error.flatten()). On success, result.data is fully typed as your exported z.infer DTO.
How do I validate query parameters that come in as strings?
Use coercion. A generated z.number() expects a real number, but req.query.page is '2'. Replace it with z.coerce.number().int() (and z.coerce.boolean() for flags). Generate the schema for the field names, then swap in coercion for any string-sourced value.
Should I turn on Strict objects for an API?
Usually yes for inputs you do not control. z.strictObject rejects fields outside your contract, which prevents stale clients and malicious extra keys (like an injected isAdmin) from slipping through. Leave it off only if you intentionally accept forward-compatible extra fields.
Can I reuse the schema as my TypeScript type?
Yes — that is the point of the exported z.infer alias. After schema.parse(body), the result is typed as your DTO, so the rest of the handler is type-checked. No separate interface needed.
What if some array elements have extra fields?
The generator unions the distinct object shapes rather than merging them into one schema with optional keys. If you want a unified element schema, normalise your sample so the array is homogeneous, then add .optional() to the genuinely-optional fields by hand.
Is the request body uploaded to validate it?
No. Schema generation is 100% in-browser. Your sample request body — even if it contains tokens or PII — never reaches JAD Apps. For peace of mind, strip secrets from the sample before pasting; you only need the shape.
How big a payload can I process on the free tier?
Free tier allows JSON files up to 2 MB and 1 generation per day, with array inputs capped at 100 rows. A single request-body sample is far below this. Pro raises it to 100 MB and 5 runs/day; Developer is unlimited.
Does it handle deeply nested request bodies?
Yes, to arbitrary depth. Every nested object becomes a nested z.object() (or z.strictObject() with Strict on), and arrays nest correctly. The structure mirrors your payload exactly with two-space indentation.
What about discriminated unions for polymorphic bodies?
The generator cannot produce z.discriminatedUnion(...) because it does not know your discriminator. It will union the observed shapes. Convert that to a discriminated union by hand using your type/kind field once you know the variants.
Can I generate validators for multiple endpoints at once?
Not in one pass — each generation produces a single root schema. Run the tool once per endpoint, naming each root after the endpoint (e.g. createUserBody, updateUserBody), and collect them into one schemas.ts module.
I want plain TypeScript types instead of Zod — what do I use?
Use json-to-typescript for interfaces, or json-schema-generator if you need a JSON Schema document for non-TypeScript validators like Ajv. This tool is Zod-specific.
Privacy first
Conversion runs locally in your browser. No file is uploaded — only metadata counters are saved for signed-in dashboard stats.