Validate an API Payload with Zod at the Edge
Define a zod schema for the request body, parse the incoming payload before the handler runs, and return a 400 with a field-level error map on failure.
Task
Reject invalid API requests at the edge with field-level errors so the handler only sees typed, valid data.
When to use this
A handler assumes email is a string and role is an enum, but the client sends a number and a typo. The handler crashes mid-transaction. The team wants validation before the handler, with errors the client can act on.
Prerequisites
- Node.js 20 LTS with zod 3 installed
- A request body parser that yields an unknown value
- An error response shape agreed with the frontend
Steps
1. Define the schema with a field-level error message for each constraint.
const schema = z.object({
email: z.string().email("email must be valid"),
role: z.enum(["admin", "user"], { errorMap: () => ({ message: "role must be admin or user" }) }),
});
Expected output: A schema that rejects a non-email string and an unknown role with the custom messages.
2. Parse the body in middleware and return 400 with the flattened error map on failure.
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
req.body = result.data;
Expected output: An invalid request gets a 400 with one error per field; a valid request reaches the handler with typed data.
3. Derive a static type from the schema so the handler has compile-time guarantees.
type Input = z.infer<typeof schema>;
function handler(body: Input) { /* body.email is string, body.role is enum */ }
Expected output: The handler parameter is typed, and a typo in a field access is a compile error, not a runtime crash.
Troubleshooting
Problem
The error map shape does not match what the frontend expects
Solution
Use flatten or format to control the shape, and document the contract in the OpenAPI spec so the frontend codes against it.
Problem
A union type produces a confusing error
Solution
Add an errorMap to the union that names each option, so the client sees which branch failed.
Limitations
- Zod validates shape, not authorization; a valid payload can still be forbidden for the caller.
- A schema that uses refinements runs them on every parse, which can be expensive on large arrays.
Validate before you handle
Parse the body in middleware, not in the handler. On failure, return a 400 with a field-level error map. On success, replace the body with the parsed value so the handler sees typed data and cannot crash on a missing field.
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
req.body = result.data;
Derive the type
Use z.infer to get a TypeScript type from the schema. The handler parameter is typed, so a typo in a field access is a compile error. The schema is the single source of truth for both runtime validation and compile-time types.
type Input = z.infer<typeof schema>;
function handler(body: Input) { /* body.email is string */ }
Expected outcome
Invalid requests get a 400 with one message per field, valid requests reach the handler with typed data, and the handler never crashes on a missing or malformed field. The schema drives both the validation and the types.