Validating FormData on the Server with Zod

How do you validate a real HTML form submission on the server — where every value is a string or a File, unchecked checkboxes are simply missing, a multi-select sends the same key several times, and address.city is a flat key rather than a nested object — using the same Zod schema the browser uses? This recipe converts FormData into a plain object that preserves repeated keys and dotted paths, models the wire format explicitly with coercion and literals, validates files alongside text fields, and returns flatten()-style field errors with a 422 status that the browser maps back onto inputs with setCustomValidity() and the Constraint Validation API.

When to Parse FormData Directly

Parse FormData whenever the server receives submissions from an HTML form — which, with progressive enhancement, is every form, even the ones your JavaScript usually submits as JSON. It is the right choice when:

  • Forms must work without JavaScript, so the browser posts application/x-www-form-urlencoded or multipart/form-data.
  • You use framework actions — Next.js server actions, React Router actions, SvelteKit form actions — which all hand you a FormData object.
  • Files are part of the submission, which JSON cannot carry.

Parsing FormData directly also means the client and server see the same shape: the browser can run schema.safeParse(formDataToObject(new FormData(form))) with the identical converter, which is the central idea of shared client–server schemas.

How form controls appear in FormData A table of form controls showing what each contributes to FormData and how the schema should model it. In FormData Model with text / email input string, "" if empty z.string() checked checkbox "on" or its value z.literal("on") unchecked checkbox key missing .optional() number input string "42" z.coerce.number() multi-select same key repeated array via getAll file input File, size 0 if none z.instanceof(File)
The wire format is strings, files and missing keys; the schema should describe exactly that, not the JavaScript types you want afterwards.

Minimal Working FormData Validator

import { z } from "zod";

/** FormData → object. Repeated keys become arrays; "a.b" and "items.0.name" become nested paths. */
export function formDataToObject(fd: FormData, arrays: string[] = []): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const key of new Set(fd.keys())) {
    const values = fd.getAll(key);
    const value = arrays.includes(key) || values.length > 1 ? values : values[0];
    setPath(out, key.split("."), value);
  }
  return out;
}

function setPath(target: Record<string, unknown>, path: string[], value: unknown): void {
  let node: any = target;
  path.forEach((part, i) => {
    const last = i === path.length - 1;
    const nextIsIndex = !last && /^\d+$/.test(path[i + 1]);
    if (last) node[part] = value;
    else node = node[part] ??= nextIsIndex ? [] : {};
  });
}

const MAX_BYTES = 5 * 1024 * 1024;

// Model the wire format: strings, "on", missing keys, Files.
export const applicationSchema = z.object({
  name: z.string().trim().min(1, "Enter your name."),
  age: z.coerce.number({ invalid_type_error: "Age must be a number." }).int("Age must be a whole number.").min(16, "You must be 16 or older."),
  newsletter: z.literal("on").optional().transform((v) => v === "on"),
  interests: z.array(z.enum(["design", "engineering", "research"])).max(3, "Choose up to 3 interests.").default([]),
  address: z.object({
    city: z.string().trim().min(1, "Enter your town or city."),
    postcode: z.string().trim().min(1, "Enter your postcode."),
  }),
  cv: z
    .instanceof(File, { message: "Attach your CV." })
    .refine((f) => f.size > 0, "Attach your CV.")
    .refine((f) => f.size <= MAX_BYTES, "Your CV must be 5 MB or smaller.")
    .refine((f) => f.type === "application/pdf", "Your CV must be a PDF."),
});

export type Application = z.output<typeof applicationSchema>;

// Route handler (framework-agnostic Fetch API style)
export async function POST(request: Request): Promise<Response> {
  const fd = await request.formData();
  const parsed = applicationSchema.safeParse(formDataToObject(fd, ["interests"]));
  if (!parsed.success) {
    // Key errors by the dotted path, which is exactly each input's name attribute.
    // (flatten() would group "address.city" under "address", losing the input it belongs to.)
    const errors = Object.fromEntries(parsed.error.issues.map((i) => [i.path.join("."), [i.message]]));
    return Response.json({ errors }, { status: 422 });
  }
  await saveApplication(parsed.data);
  return new Response(null, { status: 303, headers: { Location: "/apply/thanks" } });
}

Two decisions matter most. First, the converter is told which keys are arrays (["interests"]), because a multi-select with one option chosen sends one value, and without the hint it would arrive as a string and fail z.array. Second, errors are re-keyed by issue.path.join("."), producing address.city — the exact name attribute of the input — so the client can find the field with form.elements.namedItem(key) and call setCustomValidity() on it without a mapping table.

From multipart body to field errors The server reads the request as FormData, converts it to an object with arrays and nested paths, parses it with the shared schema, and returns dotted field errors or redirects on success. request. formData() strings and Files formDataToObje ct arrays + dotted paths schema. safeParse coerce, literal, instanceof File Errors by path "address.city": ["Enter…"] 422 or 303 re-render or redirect
Dotted error keys match input names exactly, so the browser maps them back without any translation table.

FormData Parsing Option Reference

Option Type Default Purpose
arrays hint string[] [] Keys that are always arrays, even with one value
Dotted names name="address.city" on Nested objects without JSON
Indexed names name="items.0.qty" on Arrays of objects (repeating rows)
z.coerce.number() schema Converts "42" to 42; "" becomes 0, so pair with a min or a preprocess
z.literal("on").optional() schema Checkbox checked vs missing
z.instanceof(File) schema File fields; an empty file input submits a zero-byte File
Error keys issue.path.join(".") dotted Match input name attributes directly
Success response 303 See Other redirect Post/redirect/get for no-JS submissions

z.coerce.number() has a trap: Number("") is 0, so an empty optional number field becomes 0 rather than missing. Use z.preprocess((v) => (v === "" ? undefined : v), z.coerce.number().optional()) for optional numbers.

Verification Steps

import { describe, it, expect } from "vitest";
import { POST } from "./apply";

function body(entries: Array<[string, string | File]>) {
  const fd = new FormData();
  for (const [k, v] of entries) fd.append(k, v);
  return new Request("http://test/apply", { method: "POST", body: fd });
}

describe("application FormData", () => {
  it("keys nested errors by input name", async () => {
    const res = await POST(body([
      ["name", "Ada"], ["age", "36"], ["interests", "design"],
      ["address.city", ""], ["address.postcode", "SW1A 1AA"],
      ["cv", new File(["%PDF-1.7"], "cv.pdf", { type: "application/pdf" })],
    ]));
    expect(res.status).toBe(422);
    expect((await res.json()).errors).toEqual({ "address.city": ["Enter your town or city."] });
  });
});

Edge Cases and Failure Modes

Empty file inputs. An <input type="file"> with nothing chosen still submits an entry: a File with name "" and size 0. z.instanceof(File) passes it; the size > 0 refinement is what enforces “required”.

Trusting file.type on the server. The multipart part’s content type comes from the client and can be anything. The refinement above is a first filter; sniff the bytes before accepting, as in validating file type with magic bytes.

Prototype pollution through names. A crafted field named __proto__.admin would, with a naive path setter, write to Object.prototype. Reject path segments __proto__, constructor and prototype in setPath, or build objects with Object.create(null).

const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);
if (path.some((p) => FORBIDDEN.has(p))) throw new Error("invalid field name");

Large bodies before validation. request.formData() buffers the whole body, including files, before your schema runs. Put a body-size limit in front of the route (in the framework or proxy) so a 2 GB upload is rejected while streaming, not after it has filled memory.

Running the Same Converter in the Browser

The converter is not server code; it is a pure function over FormData, which the browser has too. Put it in the shared validation package next to the schema, and the client can validate exactly what it is about to send — schema.safeParse(formDataToObject(new FormData(form), ["interests"])) — instead of building a hand-assembled object that differs subtly from the real submission. That closes a common gap: a client that validates { age: 36 } (a number it parsed itself) while the server receives "36" (a string), so a rule like z.number() passes in the browser and fails on the server. Validating the real wire format on both sides removes the entire class of bug. For file fields the browser sees the actual File objects too, so size and type refinements run before upload with no extra code, as the file upload validation topic describes.

Returning Errors for Both Script and No-Script Clients

A progressively enhanced form reaches the same route in two ways. A script-driven submission wants JSON with a 422 status; a plain form post wants HTML — the form re-rendered with the user’s values and the errors inline. Branch on the Accept header (or on a hidden field your script adds) and render both from the same errors object, so the no-script experience is the same form with the same messages, not a bare error page.

if (!parsed.success) {
  const errors = Object.fromEntries(parsed.error.issues.map((i) => [i.path.join("."), [i.message]]));
  if (request.headers.get("accept")?.includes("application/json")) {
    return Response.json({ errors }, { status: 422 });
  }
  return new Response(renderApplicationForm({ values: Object.fromEntries(fd), errors }), {
    status: 422,
    headers: { "content-type": "text/html; charset=utf-8" },
  });
}

When re-rendering, echo text values back into the inputs (escaped) so the user does not retype them — but never echo passwords or file inputs, which browsers will not let you prefill anyway. The rendered form should include an error summary at the top with links to each field, the pattern from building an accessible error summary, because without script there is no reportValidity() to move focus for the user.

One route, two kinds of client A scripted client posts FormData asking for JSON and receives a 422 JSON body; a no-script browser posts the same form and receives the re-rendered HTML form with the same errors. Scripted client No-script browser Route handler POST FormData, Accept: application/json 422 { errors: { "address.city": [...] } } POST form (urlencoded) 422 HTML form + error summary + values
Both clients get the same field errors from the same schema; only the response format differs.

Frequently Asked Questions

How do I validate FormData with Zod?

Convert it to a plain object first — handling repeated keys with getAll and dotted names as nested paths — then call schema.safeParse(). Model the wire format: strings, "on" for checked boxes, missing keys for unchecked ones, and File objects.

Why does my multi-select fail array validation when one option is chosen?

A single selection sends one value, so a naive converter produces a string. Tell the converter which keys are always arrays and read them with formData.getAll(key).

How do I validate numbers from a form on the server?

Use z.coerce.number(), and preprocess empty strings to undefined for optional numbers, because Number("") is 0.

What status code should a failed form submission return?

422 Unprocessable Content with field-keyed errors. For no-script submissions, return the re-rendered HTML form with the same errors and the user's values, also with status 422.

← Back to Shared Client–Server Schemas