Parse on entry, every time
Parsing a Server Action's untrusted FormData through a Zod schema the instant it arrives.
Picture the createInvoice action from last lesson: it takes a FormData and writes a row.
The form that posts to it sets required, min, and maxlength, so the browser refuses to submit until every field is clean, but those checks only run in that form.
The previous lesson showed the request often comes from somewhere else: a Server Action is a public POST endpoint, and the browser’s checks never ran for the request that actually arrives.
That lesson named five slots in the action and left them empty. This one fills the first: parse the input the instant it arrives, before anything else, and draw a hard line between what the schema checks and what the action body must check itself. Every piece of Zod you need is already yours from the previous chapter; what’s new is that it now has a fixed home and a fixed order.
Why client validation is never enough
Section titled “Why client validation is never enough”Here are three doors that let the same hostile object reach createInvoice, each with the browser’s constraint validation absent or stale.
Terminal
$ curl -X POST \ https://app.example.com/invoices \ -d 'total=-9999&status=god-mode' HTML5 constraint validation
did not run
No <form>, no browser. The required and pattern attributes never existed for this request.
Deploy timeline
- 09:00
- Deploy adds a required customerId field to the schema.
- 09:05
- A user with yesterday’s tab still open submits the old shape.
Client validation
passed, against the old rules
The browser validated against the contract it shipped with. The server is running the new one.
Native submit
<form action={createInvoice}> … native POST, no JS bundle</form>// progressive enhancement, covered later JS-driven checks
gone
The constraint API still covers required and type, but setCustomValidity, cross-field logic, and your Zod-in-the-browser layer never ran.
Client validation is worth doing: it gives instant feedback and saves a round-trip. But it exists for user experience, while server validation exists for correctness, so dropping the server check to avoid “repeating yourself” drops the only one that is load-bearing. Every action parses its input, every time, even behind flawless client-side validation.
Make the parse the first line
Section titled “Make the parse the first line”The opening of createInvoice turns the raw FormData into a plain object, runs it through a schema with safeParse , and branches on the outcome before touching anything else.
'use server';
export async function createInvoice( formData: FormData,): Promise<Result<{ id: string }>> { const parsed = createInvoiceSchema.safeParse( Object.fromEntries(formData), );
if (!parsed.success) { return { ok: false, error: { fieldErrors: z.treeifyError(parsed.error) }, }; }
const invoice = parsed.data; // ...authorize, mutate, revalidate, return — all reading `invoice`.}FormData in, Promise<Result<{ id: string }>> out: the shape every action shares. Result is imported as a type only; the next lesson, “Result, or throw”, defines it.
'use server';
export async function createInvoice( formData: FormData,): Promise<Result<{ id: string }>> { const parsed = createInvoiceSchema.safeParse( Object.fromEntries(formData), );
if (!parsed.success) { return { ok: false, error: { fieldErrors: z.treeifyError(parsed.error) }, }; }
const invoice = parsed.data; // ...authorize, mutate, revalidate, return — all reading `invoice`.}Object.fromEntries collapses the entries into a string-keyed object Zod can read; every value arrives as a string, which is why the schema coerces, covered next. A field that repeats (a multi-select) needs formData.getAll(name), since fromEntries keeps only the last occurrence.
'use server';
export async function createInvoice( formData: FormData,): Promise<Result<{ id: string }>> { const parsed = createInvoiceSchema.safeParse( Object.fromEntries(formData), );
if (!parsed.success) { return { ok: false, error: { fieldErrors: z.treeifyError(parsed.error) }, }; }
const invoice = parsed.data; // ...authorize, mutate, revalidate, return — all reading `invoice`.}safeParse, not parse. A parse throw escapes the form and trips the route’s error.tsx boundary, the wrong place for a bad total field; safeParse hands the failure back as a value the action can return to the form.
'use server';
export async function createInvoice( formData: FormData,): Promise<Result<{ id: string }>> { const parsed = createInvoiceSchema.safeParse( Object.fromEntries(formData), );
if (!parsed.success) { return { ok: false, error: { fieldErrors: z.treeifyError(parsed.error) }, }; }
const invoice = parsed.data; // ...authorize, mutate, revalidate, return — all reading `invoice`.}The early return. On failure the action stops and returns the validation error, carrying z.treeifyError(parsed.error), the field-keyed tree the form renders under each input. The ok flag and helpers are the next lesson’s to define.
'use server';
export async function createInvoice( formData: FormData,): Promise<Result<{ id: string }>> { const parsed = createInvoiceSchema.safeParse( Object.fromEntries(formData), );
if (!parsed.success) { return { ok: false, error: { fieldErrors: z.treeifyError(parsed.error) }, }; }
const invoice = parsed.data; // ...authorize, mutate, revalidate, return — all reading `invoice`.}The success path. Past the if, parsed.data is fully typed: a clean invoice, not a bag of unknown strings. Everything after reads from it and never touches the raw FormData again.
The shape is always convert, safeParse, return on failure, continue on success.
The word that carries the lesson is first: the parse is the first line of executable logic in the body, with nothing before it.
Three reasons stand behind that rule.
The first is leakage.
A console.log(Object.fromEntries(formData)) above the parse writes client-controlled values into your logs and leaves them there: the user who fat-fingered a password into the email field, the attacker’s stored injection payloads.
Logging raw input quietly turns an input bug into a PII incident.
If you must log, log parsed.data.
The second is the type system.
Before the parse every field is string | undefined, so branching on formData.get('plan') decides a code path on a value TypeScript can’t vouch for.
After the parse you branch on typed data.
The third is waste. Hitting the database before validating spends a query and a pooled connection to prove that garbage is garbage, which on a busy endpoint is a free denial-of-service vector for anyone spraying malformed requests. Validation is cheap; database round-trips are not.
The schema comes from the table, not your keyboard
Section titled “The schema comes from the table, not your keyboard”That parse line names a createInvoiceSchema, and where it comes from is a real decision.
Hand-writing a z.object that lists every field the form submits works until someone changes a column.
The previous chapter gave you the fix: drizzle-zod ’s createInsertSchema reads your invoicesTable and produces a schema shaped exactly like an insert into it.
The table is already the source of truth for the invoice’s shape; derive the action’s input contract from it and the two can never silently disagree.
const createInvoiceSchema = z.object({ title: z.string().max(200), total: z.coerce.number().positive(), status: z.enum(['draft', 'sent', 'paid']), dueAt: z.coerce.date(),});The drift trap. Every column is re-listed by hand. Add a 'void' status in db/schema.ts and this schema still rejects it with a confusing “invalid enum” error in production. Nothing links the two, so they drift the moment the table changes.
const createInvoiceSchema = createInsertSchema(invoicesTable, { title: (schema) => schema.max(200), total: (schema) => schema.positive(),}).omit({ id: true, organizationId: true, createdBy: true, createdAt: true });The reflex. The contract is derived from the table. Add 'void' to the column and this schema accepts it on the next build, no edit. Change a column’s type and the build either updates the contract or breaks loudly.
Three moves, all familiar from the previous chapter, turn the raw derived schema into the action’s real input contract.
.omit the columns the server sets: id is generated, organizationId comes from the session, createdBy is the signed-in user, and createdAt is stamped by the database.
None are user input, and omitting them drops them from the parsed shape, so a request smuggling in organizationId: 'some-other-org' finds no such key.
This is the previous lesson’s client-passed userId rule again: don’t validate session-owned fields, refuse to read them from the client at all.
Refine through the per-column override map, the second argument to createInsertSchema.
The column type gives some rules for free, like a varchar(200)’s length cap; rules it can’t express, like a positive-amount check, chain onto the generated column schema in that callback.
Coercion is already handled.
Every FormData value is a string, and createInsertSchema bakes in the string-to-number and string-to-date coercion a numeric or timestamp column needs.
The one exception is the HTML checkbox, which submits "on" rather than a boolean: use z.preprocess(v => v === 'on', z.boolean()), not z.coerce.boolean(), which reads the string "false" as true.
One contract binds form and schema: the form’s input name attributes match these schema keys exactly, so name="total" validates against the key total.
Wiring the form is the forms chapter’s job; for now, hold the schema as the single agreement both sides read.
Use z.strictObject for action inputs
Section titled “Use z.strictObject for action inputs”One decision remains: how the schema treats keys it doesn’t recognize.
Client and server share this schema, so a key the schema doesn’t know isn’t noise, it’s contract drift: a stale client sending a field you removed, a tampered request testing what sticks, or a plain bug.
You want to see it, not swallow it.
That rules out z.object, the convenient default, which strips unknown keys silently.
Action inputs reach for z.strictObject, which rejects an unknown key as a validation error.
| Schema | Unknown key | Use it for |
|---|---|---|
z.object | stripped silently | open inputs where extras are harmless |
z.strictObject | rejected as an error | action inputs, where an unknown key is a signal |
z.looseObject | forwarded onto the output | when you genuinely want to pass extras through |
The trade-off: strict mode also rejects fields the browser or framework adds, like a _method hidden input.
Either name those fields in the schema or, when you can’t enumerate what a form might send, accept z.object’s silent strip for that one form.
Decide per form.
A strict rejection isn’t a “correct your input” message: the user didn’t type the extra field and can’t fix it. It’s an operator signal. The error-monitoring chapter wires these into a logger so contract drift surfaces as an alert.
Quick gut-check on stripping versus rejecting:
A request hits createInvoice carrying a sneaky extra field — isAdmin: true — that isn’t in your insert schema. The schema is a plain z.object (the kind createInsertSchema generates). What happens when the parse runs?
parsed.success is false, and the action returns a validation error.parsed.success is true, and parsed.data has no isAdmin key.parsed.success is true, and parsed.data.isAdmin is true.error.tsx boundary renders.z.object drops keys it doesn’t recognize, so the parse succeeds and isAdmin never reaches parsed.data. Safe, but silent: nothing tells you a client tried. z.strictObject would turn that request into a loggable validation failure; z.looseObject would pass isAdmin straight through.Zod proves the shape; the action body proves the business rules
Section titled “Zod proves the shape; the action body proves the business rules”Zod proves the shape; the action body proves the business rules.
A passing parse tells you the input is well-formed: the right fields and types, within bounds, internally consistent. It does not tell you the input is allowed.
Some rules are provable from the submitted values alone, with no database and no network, so they belong in the schema. Others need a database row, an external service, or request state to answer, so they belong in the action body, after the parse. Sort each rule into its layer:
Sort each rule into the layer that can enforce it. Drag each item into the bucket it belongs to, then press Check.
dueAt falls after issuedAtdraft, sent, paidOne question settles any item: does answering it require IO, a database read, an external call, or request state? If yes, it belongs in the action body; if the input answers it alone, it belongs in the schema.
A world-dependent check in the schema, like a .refine that queries the database for a taken slug, is an expensive mistake: the schema can no longer parse without a live database, so you can’t unit-test it or reason about it in isolation, and you’ve welded validation to your data layer for nothing.
A business-rule failure returns through the same channel as a parse failure, but carries a different machine-readable code ('email_taken', 'plan_exceeded') and a message the form can show.
const invoice = parsed.data;
const existing = await getInvoiceByNumber(invoice.number);if (existing) { // Provisional shape — the next lesson turns this into the canonical Result. return { ok: false, error: { code: 'conflict', userMessage: 'That invoice number is already in use.' }, };}
// ...mutate, revalidate, return success.A value can be perfectly schema-valid yet still rejected: a flawlessly formatted email that sits on your suppression list, a total within the column’s range that exceeds the user’s plan. Zod was right that the shape is fine; the business rule looked at the same value and said no, and was also right. That gap is why the two layers stay separate: a passing parse is the floor, not the ceiling.
The assembled entry seam
Section titled “The assembled entry seam”In order, the pieces form the front half of every action: the signature, the parse, and the one business-rule check that needs IO.
'use server';
export async function createInvoice( formData: FormData,): Promise<Result<{ id: string }>> { // Seam 1 — parse const parsed = createInvoiceSchema.safeParse( Object.fromEntries(formData), ); if (!parsed.success) { return { ok: false, error: { fieldErrors: z.treeifyError(parsed.error) } }; } const invoice = parsed.data;
// Seam 2 — authorize (one-line check for now; wrapper lands in a later chapter)
// Seam 3 — business rules that need IO, after the parse const existing = await getInvoiceByNumber(invoice.number); if (existing) { return { ok: false, error: { code: 'conflict', userMessage: 'That invoice number is already in use.' }, }; }
// Seam 4 — mutate the database (a later lesson) // Seam 5 — revalidate the cache, then return the Result (a later lesson)}External resources
Section titled “External resources”Bookmark all three: the exact shapes Zod returns, the drizzle-zod override syntax, and the Next.js guide that argues this lesson’s thesis, that a Server Action’s arguments are hostile until you verify them.