Skip to content
Chapter 43Lesson 2

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.

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.

No form, no browser, no constraints. The action's ID is sitting in the shipped client bundle, so anyone can read it and POST to it directly.

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.

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.

1 / 1

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.

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.

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.

SchemaUnknown keyUse it for
z.objectstripped silentlyopen inputs where extras are harmless
z.strictObjectrejected as an erroraction inputs, where an unknown key is a signal
z.looseObjectforwarded onto the outputwhen 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.
The parse throws, so the route’s error.tsx boundary renders.

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.

Schema (the parse) Provable from the input alone
Action body (after the parse) Needs a database row, a service, or request state
The email is a valid email address
The total is a positive number
dueAt falls after issuedAt
The status is one of draft, sent, paid
The title is 200 characters or fewer
This email isn’t on the suppression list
This org’s slug isn’t already taken by another org
The user’s plan still allows creating an invoice
This caller is under the rate limit

One 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.

In order, the pieces form the front half of every action: the signature, the parse, and the one business-rule check that needs IO.

app/invoices/actions.ts
'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)
}

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.