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-urlencodedormultipart/form-data. - You use framework actions — Next.js server actions, React Router actions, SvelteKit form actions — which all hand you a
FormDataobject. - 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.
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.
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.
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.
Related Guides
- Shared Client–Server Schemas — running the same schema in both places.
- Handling 422 Unprocessable Content Responses — the client side of this response.
- Progressive Enhancement Without JavaScript — the no-script path in full.
- Validating Nested Object Fields — dotted names and nested schemas on the client.