Parse on entry, every time
How a Server Action validates its untrusted input, parsing FormData through a Zod schema the instant it arrives.
Picture the createInvoice action you sketched in the last lesson: it takes a FormData and writes a row.
The form that submits to it had required on the title, a min="0" on the total, and a maxlength on the notes, so the browser’s HTML5 constraint validation ran and refused to submit until every field was clean.
That makes the FormData arriving in your action trusted, right?
No.
The previous lesson showed that a Server Action is a public POST endpoint, and the browser validation never ran for the request that actually reaches it.
That lesson named five slots in the action and left them empty.
This one fills the first: the discipline of parsing your input the instant it arrives, before anything else, and drawing a hard line between what the schema checks and what the action body has to check itself.
You already own every piece of Zod machinery for this from the previous chapter: safeParse, Object.fromEntries, z.treeifyError, coercion, and createInsertSchema.
None of that is new.
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”The natural instinct is to think: I validated the form, so the data is good. Validating again on the server feels redundant. It isn’t, because the form validation and the server validation are not running for the same request, and sometimes the form validation isn’t running at all.
Here are three ways a payload your form would never let through still arrives at createInvoice.
The same hostile object reaches the action through three different doors, and in each one the browser’s constraint validation never ran.
Terminal
$ curl -X POST \ https://app.example.com/invoices \ -d 'total=-9999&status=god-mode' HTML5 constraint validation
did not run
There was no <form> and 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 gives some coverage (required, type), but setCustomValidity, cross-field logic, and your Zod-in-the-browser layer never executed.
Look at what the three doors have in common. In the first, there was no browser. In the second, the browser validated against a contract that had already changed. In the third, the browser was there but the JavaScript wasn’t. Client validation is real and worth doing, because it gives the user instant feedback and saves a round-trip, but it exists for user experience. Server validation exists for correctness. The two answer different needs, so they are not duplicates of each other, and dropping one to avoid “repeating yourself” drops the only one that’s load-bearing.
So the rule is simple: every action parses its input, every time, even when the form does flawless client-side validation. The form’s checks make the happy path pleasant. The action’s parse makes every path safe.
The parse is the first line
Section titled “The parse is the first line”The discipline has a literal shape, and it’s short.
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.
Walk through it one piece at a time.
'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 signature. FormData goes in, and a Promise<Result<{ id: string }>> comes out. Result is imported as a type only. It’s the return contract the previous lesson named, and the next lesson, “Result, or throw”, defines its body. Every action shares this in/out shape.
'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(formData) collapses the FormData entries into a string-keyed plain object Zod can read. Every value arrives as a string, which is why the schema coerces, covered in the next section. One caveat: a field that appears more than once (a multi-select, repeated checkboxes) needs formData.getAll(name) instead, because 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. parse throws, and a throw inside an action escapes past the form and trips the route’s error.tsx boundary, the wrong destination for a bad total field. safeParse hands the failure back as a value so the action can return it to the form. (When you throw versus return is its own decision, and the next lesson draws the full map.)
'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 here and returns the validation error, carrying z.treeifyError(parsed.error), the field-keyed tree the form renders under each input. Read this failure object as provisional: the ok flag, the code, and the helpers are the next lesson’s to define. Downstream, this becomes the canonical Result failure, not a hand-rolled object.
'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: TypeScript now knows it’s a clean invoice, not a bag of unknown strings. Everything after this line, authorize and mutate and revalidate, reads from parsed.data and never touches the raw FormData again.
The structure is always the same: convert, safeParse, return on failure, continue on success.
But the word that carries this whole lesson is first.
The parse is not merely early. It is the first line of executable logic in the body, and nothing runs before it.
Three reasons stand behind that rule.
The first is leakage.
The moment you write console.log(Object.fromEntries(formData)) above the parse to “see what’s coming in,” you write client-controlled values into your logs, and those logs are forever.
A user who fat-fingered their password into the email field just sent it to your log aggregator.
An attacker probing for injection just got their payloads stored in a place you’ll grep later.
Logging raw input is a quiet way to turn an input bug into a PII incident.
Parse first, and if you must log, log parsed.data, whose shape you know.
The second is the type system.
Before the parse, every field is string | undefined at best and unknown in spirit, so branching on formData.get('plan') to decide a code path means deciding on a value TypeScript can’t vouch for.
After the parse, you’re branching on typed data and the compiler has your back.
The third is waste. Hitting the database before validating means you spend a query, and a pooled connection, proving that garbage is garbage. On a busy endpoint, that’s a free denial-of-service vector handed to anyone willing to spray 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”There’s a createInvoiceSchema in that parse line, and where it comes from is a decision, not a formality.
The tempting move is to hand-write a z.object that lists every field the invoice form submits.
It works on the first day.
It breaks on the day someone changes a column.
You met the fix in the previous chapter: drizzle-zod gives you createInsertSchema, which reads your invoicesTable definition and produces a Zod schema shaped exactly like an insert into that table.
The database is already your source of truth for the invoice’s shape.
Deriving the action’s input contract from it means 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. This re-lists every column by hand. The day someone widens status with a 'void' value in db/schema.ts, this schema still rejects it, silently, with a confusing “invalid enum” error in production. Nothing connects 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 table is the source of truth, and the input contract is derived from it. Add 'void' to the column and this schema accepts it on the next build, with no edit. Change a column’s type and the build either updates the contract or breaks loudly. The drift can’t be silent anymore.
Three moves turn the raw derived schema into the action’s real input contract. You’ve seen all three before; here they just get applied at the action seam.
You .omit the columns the server sets, never the client.
id, organizationId, createdBy, and createdAt are not user input: id is generated, organizationId comes from the session, createdBy is the signed-in user the action stamps from auth, and createdAt is a timestamp the database stamps.
Omitting them keeps them out of the parsed shape entirely, which means the action cannot be tricked into setting them: a request that smuggles in organizationId: 'some-other-org' finds that key isn’t in the schema at all.
This is the same instinct as the previous lesson’s rule never to trust a client-passed userId: you don’t validate the session-owned fields, you refuse to read them from the client in the first place.
You refine on top through the per-column override map, the second argument to createInsertSchema.
The column type carries some rules for free (a varchar(200) already caps length), but rules it can’t express, like a positive-amount check or a tighter cap than the column allows, chain onto the generated column schema in that callback.
Coercion is already handled where it matters.
Because the data arrived as FormData, every value is a string, and createInsertSchema knows a numeric or timestamp column needs a string-to-number or string-to-date coercion at this boundary, so it bakes one in.
The one place to stay alert is the HTML checkbox, which submits the string "on" rather than a boolean. Reach for z.preprocess(v => v === 'on', z.boolean()) there, not z.coerce.boolean(), which would read the string "false" as true.
The form and the schema share one contract: the form’s input name attributes match these schema keys exactly.
The form posts name="total", the schema validates the key total, and there is one spelling both sides obey.
Wiring the form side to this contract is the forms chapter’s job. For now, hold onto the idea that the schema is the single agreement the client and server both read.
The strictObject reflex for action inputs
Section titled “The strictObject reflex for action inputs”One more decision lives in how the schema treats keys it doesn’t recognize, and the right answer for an action input is the opposite of the convenient one.
By default, z.object strips unknown keys: a request with an extra isAdmin: true field parses cleanly, the extra key silently dropped, and you never hear about it.
For a public form that’s often fine.
For an action input it’s a missed signal.
Your client and server share a schema, so a key the schema doesn’t know about isn’t noise, it’s contract drift: a stale client sending a field you removed, a tampered request testing what sticks, or a genuine bug.
You want to see that, not swallow it.
So action input schemas reach for z.strictObject, which rejects an unknown key as a validation error instead of stripping it.
Lay the three behaviours side by side and the choice is clear:
| 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 |
One trade-off is worth naming: strict mode will reject fields the browser or framework adds that you didn’t put there, like a _method hidden input or a framework’s own bookkeeping field.
The fix is to name those fields in the schema so they’re expected, or, when you genuinely can’t enumerate what a form might send, accept z.object’s silent strip for that one form.
State the choice per form; there’s no global right answer.
When a strict schema does reject an unknown key, that rejection is not something the user can fix, because they didn’t type the extra field. It’s a signal for you. Later in the course, the error-monitoring chapter wires these into a logger so contract drift surfaces as an alert. For now, just register that an unknown-key rejection is an operator’s problem, not a “please correct your input” message.
Quick gut-check on the difference between stripping and 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 recognise, so the parse succeeds and isAdmin simply isn’t in parsed.data — the action can never read it. Safe, but silent: nothing tells you a client tried. Swap in z.strictObject and that same request becomes a validation failure you can log; z.looseObject would do the dangerous thing and 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”One line shapes every action you’ll write, so carry it out of this lesson: Zod proves the shape; the action body proves the business rules.
A passing parse tells you the input is well-formed: the right fields, the right types, within the right bounds, internally consistent. It does not tell you the input is allowed. Those are two different questions, answered by two different layers, and confusing them is the single most common way action code goes wrong.
Some rules are provable from the input alone.
Is this a valid email? Is the total positive? Is dueAt after issuedAt? Is the status one of the few we allow? Is the title under 200 characters?
Every one of these can be answered by looking only at the submitted values, with no database and no network.
They belong in the schema.
Other rules need the rest of the world to answer. Is this email on the suppression list? Is this org slug already taken? Does this user’s plan allow another invoice? Is this caller rate-limited? None of these can be answered from the input alone, because each one needs a database row, an external service, or request state. They cannot run inside Zod, so they belong in the action body, after the parse.
Sort a handful yourself.
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, paidIf you ever hesitate over an item, one question settles it every time: does answering this rule require IO, meaning a database read, an external call, or request state? If yes, it can’t live in Zod and belongs in the action body. If no, and the input answers it on its own, it lives in the schema.
This is your first brush with one of the course’s load-bearing architectural ideas: pure validation lives in the schema, and checks that reach out to the world live at a named boundary, the action body. A later lesson in this chapter, “Thin actions, pure /lib”, develops that principle in full. For now, the IO question is enough to keep you out of trouble.
And it’s a specific, expensive trouble.
The mistake the rule prevents is cramming a world-dependent check into the schema, like a .refine that queries the database to see if a slug is taken.
Do that and your schema can no longer be parsed without a live database connection: you can’t unit-test it, you can’t reason about it in isolation, and you’ve welded your validation layer to your data layer for no reason.
Keep the schema pure, and let the action body talk to the world.
So what does a body-level rejection actually look like?
A business-rule failure flows back through the same channel as a parse failure, since the action returns it, but it carries a different machine-readable code ('email_taken', 'plan_exceeded') and a message the form can show.
One failure channel, with two kinds of rejection pouring into it.
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.One edge of this split is worth seeing directly: the narrow window of a value that’s perfectly schema-valid and still rejected. A flawlessly-formatted email address that happens to be on your suppression list. A total well within the column’s range that happens to exceed what the user’s plan permits. Zod looked at each one and said the shape is fine, and Zod was right. The business rule looked at the same value and said no, and it was also right. That gap is the entire reason the two layers are separate. A passing parse is necessary but not sufficient: it’s the floor, not the ceiling. The schema gets you a well-formed value; the action body decides whether you’re allowed to act on it.
The assembled entry seam
Section titled “The assembled entry seam”Put the pieces in order and you have the front half of every action the course writes, the signature, the parse, and the one business-rule check, stopping right where the next lessons take over.
'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)}The authorize step is a one-liner here, and the mutate and revalidate seams are still comments, because the authorization wrapper, the Result it returns, and the database write each get their own lesson.
What’s locked in now is the order, and the order is the lesson: parse first, branch on parsed.success, then prove the business rules that need the world after the parse succeeds.
Reconstruct that ordering yourself.
Order the opening lines of a correct Server Action. Drag the items into the correct order, then press Check.
FormData to an object with Object.fromEntries safeParse it against the derived schema parsed.success is false parsed.data That’s the whole discipline in one sentence: parse first, branch on parsed.success, prove the business rules after; Zod proves the shape, the action body proves the rules.
External resources
Section titled “External resources”Both references below are worth a bookmark: the Zod page for the exact safeParse and treeifyError shapes, and the drizzle-zod README for the override-map syntax you used to refine the derived schema.
The Next.js security guide is the canonical statement of this lesson’s thesis: a Server Action’s arguments are hostile until you’ve verified them.