How to build a zod validator for next.js route handlers from excel data
- Step 1Parse the uploaded workbook to JSON — In your handler or a one-off script,
const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]). Grab one fully populated row object — that is your schema sample. - Step 2Open the JSON to Zod generator — The Excel-to-Zod card redirects to /tool/json-to-zod. Paste the row object; it is parsed in the browser, nothing uploaded.
- Step 3Name it for your route — Set
rootNameto something likeimportRow, givingexport const importRowandtype ImportRowyou can import into the handler. - Step 4Decide strict vs strip for the template — Default
z.object()strips unknown columns silently. SetstrictObjects: trueforz.strictObject()if an unexpected header should be a 400. - Step 5Wire it into the Route Handler — In
app/api/import/route.ts, runconst result = importRow.safeParse(row)per row and returnNextResponse.json(result.error, { status: 400 })on failure —safeParseis the right call for user-facing routes. - Step 6Refine the columns Zod cannot infer — Add
.email()to the email column,.optional()to sometimes-blank columns, and.date()/.coerce.date()to date columns. The generator gives structure; refinements are yours.
Generated Zod vs what you add for a real route
The generator produces structure; production validation needs your refinements. Inference source: lib/json-to-zod.ts.
| Column | Generated | Add for the route |
|---|---|---|
z.string() | .email() so bad addresses 400 | |
| age | z.number().int() | .min(0).max(120) for sanity bounds |
| signupDate | z.string() | .date() or z.coerce.date() |
| referralCode (sometimes blank) | z.string() or z.null() | .optional() |
| isActive | z.boolean() | usually fine as-is |
| tags | z.array(z.string()) | .min(1) if at least one required |
parse() vs safeParse() in a Next.js handler
The generator does not write usage examples — pick the call that fits your route. Both are standard Zod.
| Call | On invalid data | Use in Next.js when |
|---|---|---|
schema.parse(row) | Throws ZodError | Internal/trusted data, or you have an error boundary that maps to 500 |
schema.safeParse(row) | Returns { success: false, error } | User-facing upload routes — return 400 with result.error.flatten() |
z.array(schema).safeParse(rows) | Validates the whole batch | Bulk Excel imports where you want all-or-nothing |
The four real generator options
There is no per-Excel options panel. Source: lib/json-to-zod.ts JsonToZodOptions.
| Option | Default | Next.js relevance |
|---|---|---|
rootName | schema | Name it importRow for a clean import into the handler |
exportAll | true | Keeps export so you can import across files |
strictObjects | false | true -> reject unexpected template columns with z.strictObject() |
indent | 2 | Match your eslint/prettier config |
Cookbook
From an uploaded sheet to a working Route Handler. Each example takes one JSON row, shows the generated Zod, then the route code you write around it. PII anonymised.
Per-row schema for an App Router upload handler
Parse the sheet, generate from one row with rootName importRow, then safeParse each row in the handler.
// 1. one row -> JSON sample pasted into the generator
{ "email": "a@x.com", "plan": "pro", "seats": 5 }
// 2. generated (rootName: importRow)
import { z } from "zod";
export const importRow = z.object({
email: z.string(),
plan: z.string(),
seats: z.number().int()
});
export type ImportRow = z.infer<typeof importRow>;
// 3. app/api/import/route.ts (your code)
const rows = XLSX.utils.sheet_to_json(sheet);
for (const row of rows) {
const r = importRow.safeParse(row);
if (!r.success) return NextResponse.json(r.error.flatten(), { status: 400 });
}Add .email() so bad addresses 400 instead of crashing
The generator emits z.string() for the email column. You add .email() to turn a malformed address into a clean 400.
Generated: email: z.string(),
Refined in your handler file:
email: z.string().email(),
Result: importRow.safeParse({ email: "not-an-email", ... })
-> { success: false } -> NextResponse.json(..., { status: 400 })strictObjects to reject a tampered template
If users must upload your exact template, strict mode turns an extra column into a validation error.
strictObjects: true ->
export const importRow = z.strictObject({
email: z.string(),
plan: z.string()
});
Upload with a sneaky extra { email, plan, isAdmin: true }:
importRow.safeParse(row) -> success:false, 'Unrecognized key: isAdmin'
The injected field never reaches your business logic.Batch-validate the whole upload
Wrap the row schema in z.array() to validate every parsed row in one call inside the handler.
import { importRow } from "./schema";
const rows = XLSX.utils.sheet_to_json(sheet);
const parsed = z.array(importRow).safeParse(rows);
if (!parsed.success) {
return NextResponse.json(parsed.error.flatten(), { status: 400 });
}
// parsed.data is fully typed as ImportRow[]Server Action reuse with z.infer
The generated z.infer<> alias removes any duplicate interface in a Server Action.
'use server';
import { importRow, type ImportRow } from "@/lib/import-schema";
export async function importExcel(rows: unknown[]) {
const clean: ImportRow[] = [];
for (const row of rows) {
const r = importRow.safeParse(row);
if (r.success) clean.push(r.data);
}
await db.insert(clean);
}Edge cases and what actually happens
Pointing the route at the upload file directly
Not acceptedThe redirect target only takes JSON. You still parse the .xlsx in your Next.js handler with SheetJS; the generator is only for producing the schema text from one sample row.
Expecting generated .parse()/.safeParse() examples
By designThe generator outputs the schema, an import, and a z.infer<> alias — no usage snippets. Choose safeParse for user-facing routes (return 400) and parse only where a throw is acceptable. Earlier drafts of this page wrongly claimed usage comments are generated.
Sometimes-blank column rejects valid rows
Add optionalIf your sample row had the column populated, it infers z.string() and a later blank row fails safeParse. The generator never adds .optional() — add it yourself, or use the tRPC Router Builder, which makes every field optional after sampling.
Date column compared with new Date() in the handler
Coerce yourselfA date cell infers z.string(). If your handler expects a Date, change it to z.coerce.date() so safeParse returns a real Date object, otherwise you compare a string.
Numbers arriving as strings from the form upload
Coerce or 400If the upload pipeline stringifies cells ("5" instead of 5), z.number() rejects it. Either fix the parse step (SheetJS keeps numbers as numbers) or switch to z.coerce.number() in the schema.
Extra columns silently dropped
Strip by defaultDefault z.object() strips keys not in the schema, so an unexpected column passes validation and quietly disappears from result.data. Use strictObjects: true (-> z.strictObject()) if a stray column should be a 400.
Invalid JSON pasted from a half-copied row
Parse errorThe generator runs JSON.parse; a partial copy or trailing comma throws and produces nothing. Copy a complete object, or stringify the row in your script (JSON.stringify(rows[0], null, 2)) before pasting.
First array element under-represents the columns
Inference driftArray inference reads element zero only. If row one is missing a column, the schema omits it. Paste a fully populated single object instead of the array when building the schema.
Sample over the free 2 MB cap
413 limitJSON to Zod is Pro with a 2 MB free file limit. You only need one row to infer a schema, so trim the sample — never paste the entire workbook's JSON.
Edge runtime expectations
SupportedThe generated schema is plain Zod with no Node APIs, so it runs in the Edge runtime, Node, Deno, and Bun alike. The SheetJS parse step that produces your JSON is the only part with runtime requirements.
Frequently asked questions
Can I upload the Excel file to generate the schema?
No. The Excel-to-Zod card redirects to /tool/json-to-zod, which takes a JSON sample. Parse the upload with SheetJS in your Next.js handler, then paste one row to generate the schema. For direct .xlsx input use the tRPC Router Builder.
Should I use parse() or safeParse() in a route handler?
Use safeParse() for user-facing upload routes — it returns { success, error } so you can respond with a 400 and error.flatten() instead of throwing a 500. Reserve parse() for trusted internal data.
Does it generate the .optional() wrappers for me?
No. It reads one sample value and never adds .optional(). Add it to any column that is blank in some rows, or use the tRPC Router Builder, which wraps every field in .optional().
How do I reject an upload that has extra columns?
Set strictObjects: true to get z.strictObject(). Any column not in the schema causes safeParse to fail, which you map to a 400. The default z.object() silently strips extras instead.
How do I validate every row at once?
Wrap the generated schema: z.array(importRow).safeParse(rows). On success, parsed.data is typed as your row type array; on failure you get aggregated errors for a single 400 response.
Will my customer data be uploaded?
No. Conversion runs in the browser (convertJsonToZod). Your sample row never reaches a JAD server, and the .xlsx parsing happens in your own Next.js handler. Only an anonymous run counter is logged.
How do I handle date columns?
They infer as z.string(). Change them to z.coerce.date() if your handler needs a real Date, or z.string().datetime() to validate the ISO format without coercing.
Does the schema work in the Edge runtime?
Yes. The output is plain Zod with no Node-only APIs, so it runs in Edge, Node, Deno, and Bun. Only the SheetJS parsing step has any runtime considerations.
Can I get a TypeScript type as well?
Yes — every output includes type <Name> = z.infer<typeof <name>>, so your handler is typed from the same schema. No separate interface needed.
What's the free size limit?
JSON to Zod is Pro with a 2 MB free file cap. Since you only paste one representative row, the limit is rarely a factor.
Numbers come through as strings — what do I do?
SheetJS normally preserves numbers as numbers. If your pipeline stringifies them, either fix the parse step or use z.coerce.number() so the schema accepts "5" and yields 5.
Is there an Excel tool that builds the whole API, not just the schema?
Yes — the tRPC Router Builder reads your sheet and emits a Zod schema, a TypeScript interface, and list/get/create/update/delete procedures. For Python-side imports see the Python dict generator.
Privacy first
Every JAD Excel tool runs entirely in your browser using SheetJS and ExcelJS. Your spreadsheets, formulas, and data never leave your device — verified by zero outbound network requests during processing.