How to convert excel column headers to a type-safe trpc router
- Step 1Confirm you are on the Developer tier — The tRPC Router Builder is Developer-only. On Free / Pro / Pro+Media the tool throws
tRPC Router Builder requires Developer tier.and shows an upgrade overlay instead of generating code. Sign in on a Developer plan first. - Step 2Put your field list in a header row — The first row of the sheet is read as column names — they become the Zod fields. Use clean header text (
firstName,email,isActive); the tool camelCases each one for the schema. Keep at least a handful of representative data rows below the header so type inference has samples to read. - Step 3Drop the file onto the tool — Accepts
.xlsx,.xls,.ods, and.csv. SheetJS parses it in your browser. For a single-sheet workbook you get one flat CRUD router; a multi-sheet workbook produces a root router with one sub-router per sheet (see the multi-sheet example). - Step 4Set the router name — The one option is Router name (default
dataRouter, placeholderusersRouter/productsRouter). The entity name is derived from it: the trailingrouteris stripped and the rest is PascalCased, soproductsRouteryields theProductsentity,${Products}Schema, andctx.db.products. There is no separate entity field. - Step 5Run and review the inferred types — The output appears in a code preview (truncated at 4,000 characters — Download for the full file). Scan the
z.object()block: a numeric-looking ID column will bez.number(), a date column staysz.string(). Adjust by hand where you want stricter validation (e.g. change az.string()toz.string().email()). - Step 6Copy or download into your project — Use Copy for the visible preview or Download for the full
<routerName>.ts. Drop it intoserver/api/routers/, then replace eachctx.db.<entity>.*placeholder with your Prisma, Drizzle, or custom data-access call and register the router on your rootappRouter.
How column types are inferred
The tool samples the first 50 non-empty values of each column (cells that are null, undefined, or empty string are skipped). Rules are checked in order; the first that matches every sample wins, otherwise the column falls back to z.string().
| Sample values in the column | Inferred Zod type | Inferred TS type | Note |
|---|---|---|---|
1, 2, 42, 3.14, -7 (or numeric strings "42") | z.number() | number | Matches ^-?\d+(\.\d+)?$ for every sample, or the cell is already a JS number |
2026-01-15, 2026-06-10 (ISO date prefix) | z.string() | string | Date columns are kept as string, not z.date() — tighten by hand if you need a real date type |
true, false (or the booleans true/false) | z.boolean() | boolean | Every sample must be exactly true/false or the strings "true"/"false" |
Alice, acme@x.com, mixed text | z.string() | string | Default fallback for anything that isn't all-numeric, all-ISO-date, or all-boolean |
| Empty column (all 50 samples blank) | z.string() | string | No non-empty samples → no rule fires → falls back to z.string() |
Mixed 42, n/a, Alice | z.string() | string | One non-numeric sample disqualifies z.number(); the column becomes z.string() |
What the generated router contains
A single-sheet upload produces this flat CRUD shape. Procedure kind and the placeholder data call are fixed; only field names and types vary with your data.
| Procedure | Kind | Input (Zod) | Placeholder data call |
|---|---|---|---|
list | publicProcedure (query) | page (int ≥1, default 1), pageSize (int 1–100, default 20), search? | ctx.db.<entity>.findMany({ skip, take, where }) — where searches the first 2 string columns with contains + mode: "insensitive" |
getById | publicProcedure (query) | { id: z.string().uuid() } | ctx.db.<entity>.findUnique({ where: { id } }); throws TRPCError NOT_FOUND if missing |
create | protectedProcedure (mutation) | ${entity}CreateSchema (= ${Entity}Schema.required()) | ctx.db.<entity>.create({ data: input }) |
update | protectedProcedure (mutation) | ${entity}UpdateSchema (= .partial() + id: z.string().uuid()) | ctx.db.<entity>.update({ where: { id }, data }) |
delete | protectedProcedure (mutation) | { id: z.string().uuid() } | ctx.db.<entity>.delete({ where: { id } }); returns { success: true } |
Inputs, output, and tier limits
The tool is Developer-only; the limits below are the Developer-tier excel-family limits used by every Excel tool. It is single-file (drop one workbook).
| Property | Value |
|---|---|
| Accepted input | .xlsx, .xls, .ods, .csv (SheetJS, browser-side) |
| Output | One UTF-8 .ts text file named <routerName>.ts |
| Options | routerName (string, default dataRouter) — the only control |
| Required tier | Developer (lower tiers see an upgrade overlay) |
| Developer file size | 500 MB per file |
| Developer row limit | Unlimited |
| Files per run | One workbook (multi-sheet workbooks generate sub-routers automatically) |
| tRPC target | v11 — import { router, publicProcedure, protectedProcedure } from "../trpc" + TRPCError from @trpc/server |
Cookbook
Real input headers and the exact generated output for the most common shapes. Output is trimmed to the relevant lines.
A users sheet to a flat CRUD router
A single-sheet workbook with id, name, email, and an active flag. The router name usersRouter yields the User entity (trailing router stripped, PascalCased). Note id is numeric in the sample so it becomes z.number(), while the UUID validation on getById/update is fixed by the generator regardless of the id column's inferred type.
Input (Sheet1, first row = headers):
id,name,email,active
1,Alice,alice@acme.com,true
2,Bob,bob@acme.com,false
Router name: usersRouter
Generated usersRouter.ts (schema + list excerpt):
export const UserSchema = z.object({
id: z.number().optional(),
name: z.string().optional(),
email: z.string().optional(),
active: z.boolean().optional(),
});
export const userCreateSchema = UserSchema.required();
export const userUpdateSchema = UserSchema.partial().extend({
id: z.string().uuid(),
});
export interface User { id?: number; name?: string; email?: string; active?: boolean; }
export const usersRouter = router({
list: publicProcedure
.input(z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
search: z.string().optional(),
}))
.query(async ({ input, ctx }) => { /* ctx.db.user.findMany(...) */ }),
// getById, create, update, delete follow
});
export type UserRouter = typeof usersRouter;Search targets the first two string columns
The list procedure's optional search builds a case-insensitive contains filter — but only over the first two columns the tool typed as z.string(). Here name and email are the first two strings, so search hits both; a later notes column is ignored by search (you'd add it by hand).
Headers: id, name, email, signupDate, notes
id -> z.number()
name -> z.string() (1st string)
email -> z.string() (2nd string)
signupDate-> z.string() (ISO date, kept as string)
notes -> z.string()
Generated where clause (search non-empty):
where: search ? {
OR: [
{ name: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
],
} : undefined
// notes is NOT searched — only the first 2 string columns are wired in.A multi-sheet workbook becomes a nested router
When the workbook has more than one non-empty sheet, the tool emits a root router with one sub-router per sheet. Critically, sub-router entity names come from the SHEET NAMES, not from the router name — so name your tabs deliberately.
Workbook tabs: Users | Products
Router name: appRouter
Generated appRouter.ts (structure):
// Schemas for both entities (UserSchema, ProductSchema...)
const usersRouter = router({ /* list/getById/create/update/delete for User */ });
const productsRouter = router({ /* ...for Product */ });
export const appRouter = router({
users: usersRouter,
products: productsRouter,
});
export type AppRouter = typeof appRouter;
// Entity names = sheet names (Users -> User, Products -> Product),
// independent of the routerName you typed.Numeric ID column inferred as z.number()
Spreadsheets almost always store IDs as plain integers, so the id column is typed z.number() in the schema and interface — even though getById and the update key are hard-coded to z.string().uuid(). Decide whether your real keys are numeric or UUID and reconcile after generating.
Input:
id,sku,price
1001,ABC-1,19.99
1002,DEF-2,4.50
Generated schema fields:
id: z.number().optional() // numeric samples -> z.number()
sku: z.string().optional()
price: z.number().optional() // 19.99 / 4.50 match the numeric regex
// But getById input is fixed: z.object({ id: z.string().uuid() })
// If your real id is a numeric PK, change this to z.number() by hand.Empty / sparse columns fall back to z.string()
A column whose first 50 sampled cells are all blank has no samples to type, so no rule fires and it defaults to z.string(). Same outcome for a column that mixes a number with any non-numeric value — one stray n/a disqualifies z.number().
Input:
id,middleName,age
1,,30
2,,n/a
Inference:
id -> z.number() (1, 2)
middleName -> z.string() (all samples empty -> fallback)
age -> z.string() ('n/a' present -> not all-numeric)
// Clean or backfill columns before generating if you want stronger types.Edge cases and what actually happens
Run on Free / Pro / Pro+Media tier
Developer requiredThe processor checks the tier and throws tRPC Router Builder requires Developer tier. before doing any work; the UI shows an upgrade overlay (requires the Developer plan) over the form. This is the most common reason no output appears. Upgrade to Developer to generate routers.
Date columns are typed as z.string(), not z.date()
By designWhen every sampled value in a column matches the ISO date prefix YYYY-MM-DD, the tool deliberately assigns z.string() (not z.date() or z.coerce.date()). The generated schema validates the shape as text. If you want real date validation, change those fields to z.string().datetime() or z.coerce.date() after generating.
Every field is marked .optional()
ExpectedThe base ${Entity}Schema makes every field .optional(). ${entity}CreateSchema re-tightens with .required() (all fields mandatory on create) and ${entity}UpdateSchema uses .partial() plus a required id: z.string().uuid(). If some fields should be required on read too, edit the base schema by hand — the generator can't know which columns are nullable in your DB.
getById / update id is always z.string().uuid()
By designRegardless of how the tool typed your id column, the getById input, the delete input, and the update schema's id are hard-wired to z.string().uuid(). If your primary key is a numeric autoincrement or a CUID, replace z.string().uuid() with the correct validator in those three places after generating.
No header row in the file
Headers from row 1SheetJS reads the first row as column keys. If your first row is actual data instead of headers, those data values become the Zod field names (e.g. a field literally named Alice). Always keep a clean header row at the top before uploading.
Duplicate column headers
Auto-suffixedTwo columns named email are disambiguated by SheetJS (the second becomes email_1 or similar) so the keys stay unique — meaning your schema gets a field with the suffixed name. Rename duplicate headers in the source sheet to get clean field names.
Headers with spaces or punctuation
camelCasedA header like First Name or sign-up date is converted to a camelCase field (firstName, signUpDate) for both the Zod schema and the TS interface. The transformation strips non-alphanumeric separators and uppercases the following letter; verify the result reads as you expect for unusual headers.
Empty sheet or workbook with no rows
Empty outputIf no sheet has any data rows, the tool returns a placeholder // No data text result rather than a router. Make sure at least one sheet has a header row plus data before running.
Sub-router entity names come from sheet names
Multi-sheet behaviourIn a multi-sheet workbook, each sub-router's entity name is derived from its sheet/tab name (PascalCased), not from the router name you typed. A tab called Sheet1 produces a Sheet1 entity. Rename tabs to your intended entity names before generating.
Output preview is truncated
Preview onlyThe on-page code preview shows the first 4,000 characters and appends a truncation note for larger files. The full router is always intact — use the Download button (or Copy, which copies the complete generated string) to get every line.
Frequently asked questions
What tRPC version does the generated code target?
tRPC v11. The file imports { router, publicProcedure, protectedProcedure } from "../trpc" and { TRPCError } from "@trpc/server", using the v11 router() / *.input().query() / *.input().mutation() pattern. Adjust the relative ../trpc import path to wherever your tRPC instance is defined.
How are column types inferred?
The tool samples the first 50 non-empty values of each column. If they're all numeric (or numeric strings) the column becomes z.number(); if they all match the ISO date prefix YYYY-MM-DD it stays z.string(); if they're all true/false it becomes z.boolean(); otherwise z.string(). Every field is .optional() in the base schema.
Why is my date column z.string() instead of z.date()?
By design. The generator keeps ISO-date columns as z.string() rather than emitting z.date() or z.coerce.date(). If you want real date handling, change those specific fields to z.coerce.date() or z.string().datetime() after generating — the structure is otherwise ready.
Where does the entity name come from?
For a single sheet, it's derived from the router name: the trailing router is stripped and the rest is PascalCased (usersRouter → User). For a multi-sheet workbook, each sub-router's entity name comes from its sheet name instead. There is no separate entity-name input.
What's the only configurable option?
Just Router name (a text field, default dataRouter). Everything else — field names, types, procedure set, pagination shape — is derived from your data and is fixed. The output file is named <routerName>.ts.
Does it generate database queries?
It generates Prisma-style ctx.db.<entity>.findMany / findUnique / create / update / delete placeholders. They compile against a Prisma-shaped ctx.db, but you should replace them with your actual data-access calls (Prisma, Drizzle, raw SQL, etc.). The Zod schemas and router structure are ORM-agnostic.
Is this really free / what tier do I need?
The tRPC Router Builder is Developer-tier only. On Free, Pro, or Pro+Media it shows an upgrade overlay and throws tRPC Router Builder requires Developer tier. instead of generating. The Developer plan also raises the per-file limit to 500 MB with unlimited rows.
Does my spreadsheet get uploaded anywhere?
No. Parsing runs entirely in your browser via SheetJS — column names and sample data never reach a server. Only an anonymous run counter is recorded for signed-in dashboard stats; no file content is stored.
Can I feed it a CSV instead of an Excel file?
Yes — it accepts .csv alongside .xlsx, .xls, and .ods. SheetJS parses the CSV the same way: first row as headers, subsequent rows as data for type inference. A single-table CSV produces a flat CRUD router.
How does the search parameter work?
The list procedure accepts an optional search string and builds a Prisma where with OR over the first two columns typed as z.string(), each using { contains: search, mode: "insensitive" }. Other string columns aren't searched automatically — add them to the OR array if you need them.
Will the create/update schemas match my real model?
${entity}CreateSchema is ${Entity}Schema.required() (everything mandatory) and ${entity}UpdateSchema is .partial() plus a required id: z.string().uuid(). These are sensible defaults but won't know your nullable columns or your real key type — review and tighten after generating, especially the id validator.
Can I script this as part of a build pipeline?
Yes, on Developer tier. GET /api/v1/tools/excel-trpc-router returns the tool's option schema (routerName); pair the @jadapps/runner once and POST your workbook + { routerName } to the runner at http://127.0.0.1:9789 so the file is parsed locally and the .ts router is returned. Regenerate whenever the source spreadsheet changes. If you only need the Zod schema (no router) use json-to-zod; for a Python/pandas binding from the same sheet see excel-python-gen.
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.