Skip to content
intermediate Phase 6 · Server Actions and Forms

Validating Action Input

Check server-side input with schemas before mutating.

45m
0 problems
Topic Progress 0%

From FormData to Typed Values: safeParse and the Field Error Map

An action receives FormData, which is a bag of strings — every value arrives as string | File | null, regardless of what the input's type said. The job of validation is to convert that bag into typed, checked values or a structured explanation of exactly what was wrong. Zod does both in one call:

// lib/schemas.ts
import { z } from 'zod';

export const SignupSchema = z.object({
  email: z.string().trim().min(1, 'Email is required').email('Enter a valid email'),
  password: z.string().min(12, 'At least 12 characters'),
  plan: z.enum(['free', 'pro', 'team']),
  seats: z.coerce.number().int().min(1).max(100),   // FormData gives strings
});

export type Signup = z.infer<typeof SignupSchema>;

Inside the action, branch on safeParse — never parse, which throws a ZodError you would then have to catch and translate anyway:

'use server';
export async function signup(prev: State, formData: FormData): Promise<State> {
  const result = SignupSchema.safeParse(Object.fromEntries(formData));
  if (!result.success) {
    return { errors: fieldErrors(result.error) };   // field -> message
  }
  const data: Signup = result.data;                // typed, numbers are numbers
  await db.user.create({ data: { email: data.email, password: await hash(data.password) } });
  return { status: 'success', message: 'Check your inbox' };
}

fieldErrors is the small adapter that makes the whole thing renderable. Zod reports issues as a flat array with a path; group them by their first path segment and the template can highlight each input:

function fieldErrors(error: z.ZodError) {
  const errors: Record<string, string> = {};
  for (const issue of error.issues) {
    const key = String(issue.path[0] ?? 'form');
    errors[key] ??= issue.message;      // first message per field wins
  }
  return errors;
}

Now state.errors.email lines up with <input name='email'> — a naming contract between schema and markup worth enforcing, because the moment they drift (username vs user_name) the error silently renders nowhere. Cross-field failures (password confirmation, end date after start date) use a form-level key: z.refine on the object produces an issue with an empty path, which your adapter files under form for a message shown once above the submit button.

Coercion deserves its own warning. z.coerce.number() accepts '' and turns it into 0, so an empty seat count silently passes min(1) only if 0 < 1 fails — it does fail, which is what you want, but z.coerce.number() on a field with no min will happily create zero-valued records from empty inputs. The tighter idiom for optional numbers is z.union([z.literal(''), z.coerce.number()]).transform(...) or preprocessing that maps '' to undefined before the schema sees it. Decide defaults explicitly: a default applied in the schema means "absent and empty are the same thing" (fine for a plan radio group with a checked default), while a default applied in the caller means the schema still rejects missing input (right when the client must send it).

The payoff is a single declaration driving three things: the TypeScript type of the record you insert, the messages the form displays, and the boundary between untrusted strings and typed data. Everything downstream — the db.user.create call, the API you expose later — receives Signup, not FormData.

A note on where the schema lives: keep it in a shared module (lib/schemas.ts) with no server-only imports, because both the client form and the action will import it. Bundlers tree-shake unused Zod features, but a schema that accidentally imports your ORM forces the whole database layer into the browser bundle — a review check worth doing on every pull request that touches validation. The schema is the one artifact in this flow used on both sides of the boundary, so it must be as boring and dependency-free as you can make it.

Validate Twice, Trust Once: Client Feedback and Server Authority

Validation runs in two places for two different reasons, and conflating them produces either sluggish forms or insecure ones.

The browser pass exists for speed. Before submitting, parse the same schema in the client so the user gets an instant message without a round trip:

'use client';
import { SignupSchema } from '../lib/schemas';   // schema shared, not duplicated

function onSubmit(e: React.FormEvent<HTMLFormElement>) {
  const data = Object.fromEntries(new FormData(e.currentTarget));
  const result = SignupSchema.safeParse(data);
  if (!result.success) {
    e.preventDefault();                          // stop the request
    setErrors(fieldErrors(result.error));        // show inline now
  }
}

<form action={signup} onSubmit={onSubmit}> ... </form>

Keeping one schema for both sides is the whole trick: a second, hand-written browser rule set drifts the moment someone adds a field, and then the client says "fine" while the server says "no" — the worst possible feedback. Sharing also keeps bundle cost honest: Zod in a client component means the schema lands in the browser bundle, so for large schemas it is worth importing it lazily or sticking to the server pass for heavyweight rules.

The server pass exists because it is the only one that counts. The client check can be skipped entirely: disable JavaScript, curl the action's identifier with crafted fields, or modify the request in DevTools. The action is a public endpoint, so its schema parse is the authorization boundary for input — everything before it is UX. The rule to say out loud in an interview: client validation is a courtesy, server validation is the contract.

Why this matters structurally: required on an input, a minLength attribute, and a disabled button all vanish when JS is off. The no-JS form path from Progressive Forms with Actions posts directly to the server, and it should still be safe — it just gets coarser messages (a rendered form with field errors, back from the server) instead of instant ones.

The two passes also differ in what they can see. Cross-request facts — email already registered, slug taken, quota exhausted — exist only on the server, so they belong in the action no matter how thorough the browser check was. Do them after the schema parse (so your query parameters are typed) and before the write, inside a transaction where a race can be handled by a unique constraint rather than a read-then-write check:

const data = SignupSchema.parse(...);              // shape
try {
  await db.user.create({ data: { email: data.email } });  // unique index enforces
} catch (e) {
  if (isUniqueViolation(e)) return { errors: { email: 'Already registered' } };
  throw e;                                          // real failure -> error.tsx
}

Read-then-check (findUnique then create) is the classic TOCTOU bug: two concurrent signups both see "available" and one explodes on insert anyway. Let the database be the final validator of uniqueness, and map its error back into the same field-error shape.

Cost/benefit, in one table:

Check Where Skippable? Latency
shape, format, ranges client (same schema) + server client: yes server: one round trip
authn/authz, ownership server only no in-request
uniqueness, quotas, balances server only, after shape no one query

A complete answer to "how do you validate a server action?" walks that table top to bottom and never claims the browser check is a security control.

Untrusted by Default: Direct Posts, Coercion Traps, and Cross-Field Rules

Everything arriving in an action's arguments was typed by someone who is not your user — or not a user at all. Three classes of attack are worth being able to name, because each has a different fix and the schema only covers one of them.

1. Type and shape attacks. Send seats: '999999999', plan: 'enterprise', or omit email entirely. The Zod object schema handles all of these if — and only if — every field is declared and the object is built with z.strictObject (or .strict()) so unknown keys are rejected rather than stripped. Non-strict objects silently drop extra fields, which is usually fine, but a strict schema turns "someone is probing the endpoint" into a logged error instead of an invisible no-op. The related trap: Object.fromEntries(formData) gives you every submitted field including ones with no input on your page, so the schema is the only thing standing between the form and an attacker's injected key.

2. Semantic attacks that pass the schema. role: 'admin' validates beautifully against z.enum(['user', 'admin']) — if you let the client supply role at all. The fix is structural: fields that determine privilege never come from input. Take them from the session:

const session = await requireUser();
const parsed = SignupSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { errors: fieldErrors(parsed.error) };
const role = 'user';                 // decided here, not in the form
await db.user.create({ data: { ...parsed.data, role, orgId: session.orgId } });

The schema validates what the user typed; the server decides who the user is. Any review comment along the lines of "why is role in the form?" is a genuine security finding, not style.

3. Where validated data goes afterwards. A string that passed email() is still a string when it reaches a template, a URL builder, or a SQL driver. Validation proves format, not safety of use: interpolation into a template needs escaping, concatenation into a path needs a resolved-basename check, and queries go through the driver's parameterization regardless. Treat the schema as proof the value is shaped correctly, then apply the normal discipline for that value's destination.

Cross-field rules are where people under-use schemas. Refinements live on the object, and their message needs a home — route it to a form-level key so it renders once:

export const BookingSchema = z
  .object({
    start: z.string().min(1),
    end: z.string().min(1),
    guests: z.coerce.number().int().min(1),
  })
  .refine((b) => b.end > b.start, {
    message: 'End must be after start',
    path: ['end'],                     // attach the message to a field
  })
  .refine((b) => b.guests <= b.roomCapacity, {
    message: 'Too many guests for this room',
    path: ['guests'],
  });

Note path: ['end'] — with a path, the message lands on a field key your template already renders; without one, it falls into the form-level bucket and needs a spot in the UI. Comparing date strings lexicographically works only when both are ISO-formatted; for anything else, parse to Date in the schema (z.coerce.date()) and compare real values.

The interview framing that lands: "I parse FormData with a shared schema in the client for immediate feedback and again in the action as the authority, map Zod issues to field keys, add refinements for cross-field rules, keep privilege out of input, and let a unique constraint settle races." That is a complete answer — it names the boundary, the failure modes, and who is actually responsible for each check.

Quiz

1. An action does `z.coerce.number()` on a field that arrives as an empty string from an optional text input. What happens?

Question 1 options

2. What is the correct division of labor between client-side and server-side validation?

Question 2 options

3. Your template never displays the 'End must be after start' message even though the schema rejects the input. Why?

Question 3 options

4. Your action accepts role: z.enum(['user','admin']) from FormData and uses it in the insert. What is the flaw?

Question 4 options

Flashcards

Question

safeParse vs parse inside an action, and what you do with the result?

Answer

safeParse returns { success: true, data } or { success: false, error } — no throw, so you can branch and return field errors. parse throws ZodError, which would propagate to error.tsx instead of rendering inline. Group error.issues by path[0] into a Record<field, message> for the template.

Question

Why share one schema between client and server?

Answer

The client pass (safeParse before submit, preventDefault on failure) gives instant feedback with zero drift; the server pass is the authority because the client can be skipped. One declaration drives the TS type, both checks, and the field-error messages — a duplicated browser rule set diverges the moment a field changes.

Question

What does FormData deliver, and which schema feature handles numbers and strictness?

Answer

Everything is string | File | null — so use z.coerce.number() carefully (empty string coerces to 0; preprocess '' to undefined) and z.enum for closed sets. z.strictObject rejects unknown keys, turning injected fields into errors instead of silently stripped noise.

Question

Cross-field validation: where does the message go and how do you attach it to a field?

Answer

z.object(...).refine(predicate, { message, path: ['end'] }) runs after shape checks. With a path the message lands on that field key; without one it is form-level and needs its own render slot. Compare real Dates, not raw strings, unless both are ISO.

Question

The three attack classes an action schema alone does not stop?

Answer

1) shape/type probes — handled by the schema (strict, bounded, typed); 2) semantic attacks like role: 'admin' — fix structurally by taking privilege from the session, not input; 3) post-validation misuse — validation proves format, not safety for URLs, paths, or templates. Uniqueness/quota races belong to the DB constraint, not a read-then-write check.

Revision Notes

Key Takeaways

  • 1. safeParse returns typed data or a per-field error map — parse() throws, which sends expected validation failures to error.tsx instead of inline
  • 2. FormData is strings; coercion needs explicit empty-string handling and closed sets need enums
  • 3. One schema drives both passes: client for latency, server for truth — the client pass is skippable by design
  • 4. Field keys are a contract between schema path[0] and input name; refinement messages need a path or a rendered form-level bucket
  • 5. Privilege never comes from input, and uniqueness belongs to a DB constraint — the schema validates shape, not identity or races

Interview Tips

  • • "How do you validate server action input?" — safeParse(Object.fromEntries(formData)), map issues to field keys, return them; then add the server-only checks (uniqueness, quotas) after shape validation
  • • "Client or server validation?" — both, for different reasons: client is UX (skippable), server is the contract; mention sharing one schema so they cannot drift
  • • "Explain a zod refine" — object-level cross-field rule after shape checks, with path: ['field'] to make the message render where the user can act on it
  • • "How do you stop role escalation?" — take privilege from the session, not the form; a schema that accepts role from input is the vulnerability, regardless of enum bounds

Cheat Sheet

Validating Action Input

Shared schema:

export const SignupSchema = z.object({
  email: z.string().trim().email('Enter a valid email'),
  password: z.string().min(12, 'At least 12 characters'),
  plan: z.enum(['free', 'pro', 'team']),
  seats: z.coerce.number().int().min(1).max(100),
});
export type Signup = z.infer<typeof SignupSchema>;

In the action (authority):

const result = SignupSchema.safeParse(Object.fromEntries(formData));
if (!result.success) return { errors: fieldErrors(result.error) };
const data: Signup = result.data;
// privilege/ownership from the session, never from input

fieldErrors: group error.issues by String(issue.path[0]), first message wins; empty path = form-level bucket (render it or set a path).

In the client (courtesy): same schema in onSubmit, preventDefault() on failure for instant feedback. Shared, never duplicated.

Gotchas: z.coerce.number('') → 0 (preprocess empties) · non-strict object strips unknown keys → z.strictObject · uniqueness via unique constraint + mapped error, not read-then-write · role/orgId come from session.

Refinements: .refine(pred, { message, path: ['end'] }) for cross-field rules; message attaches to that field key.

Continue Learning