Programming Intermediate 9 min

How to Validate API Input in Node.js with Zod

Every value in req.body is untrusted. A user can send missing fields, the wrong type, an empty string where you expect an email, or a 10 MB blob where you expect a name. Without validation you either crash or corrupt your database.

Zod is the best validation library for Node.js projects that use TypeScript — it defines a schema once, validates at runtime, and infers static TypeScript types from the same schema so there is zero duplication. This guide covers defining schemas, parsing request bodies, returning structured errors, writing a reusable validate middleware, and using Zod for sanitisation as well as validation.

Step-by-step

  1. 1

    Install Zod

    Zod has zero runtime dependencies and works with plain JavaScript or TypeScript. Install it once and use it across the entire project.

    bash
    npm install zod
  2. 2

    Define your first schema

    A Zod schema describes the exact shape and constraints you expect. Declare it at the top of the file (or in a dedicated schemas/ folder) so the same schema can be shared by the route handler and any unit tests.

    typescript
    // src/schemas/user.schema.ts (or .js)
    import { z } from "zod"
    
    export const createUserSchema = z.object({
      name: z.string().min(2).max(100).trim(),
      email: z.string().email().toLowerCase(),
      age: z.number().int().min(18).max(120),
      role: z.enum(["admin", "user"]).default("user"),
    })
    
    // TypeScript: infer the type — no separate interface needed
    export type CreateUserInput = z.infer<typeof createUserSchema>
    // { name: string; email: string; age: number; role: "admin" | "user" }
  3. 3

    Use safeParse to validate a request

    schema.parse() throws on failure. schema.safeParse() returns a result object instead — use it in route handlers so you control the response. On success, result.data is the parsed, sanitised value (trimmed strings, defaults applied). On failure, result.error contains the full list of issues.

    typescript
    // src/controllers/usersController.ts
    import { createUserSchema } from "../schemas/user.schema"
    
    export const create = (req: Request, res: Response) => {
      const result = createUserSchema.safeParse(req.body)
    
      if (!result.success) {
        return res.status(422).json({
          error: "Validation failed",
          issues: result.error.issues.map((issue) => ({
            field: issue.path.join("."),
            message: issue.message,
          })),
        })
      }
    
      // result.data is fully typed and safe to use
      const { name, email, age, role } = result.data
      // ... save to DB
      res.status(201).json({ name, email, age, role })
    }
  4. 4

    Return structured validation errors

    Clients need machine-readable error details, not just a generic 422. The format below gives each issue its field path and a human-readable message — easy to display inline next to form fields.

    json
    // Example response body when validation fails
    // POST /api/users  {"name":"A","email":"not-an-email","age":15}
    {
      "error": "Validation failed",
      "issues": [
        {
          "field": "name",
          "message": "String must contain at least 2 character(s)"
        },
        {
          "field": "email",
          "message": "Invalid email"
        },
        {
          "field": "age",
          "message": "Number must be greater than or equal to 18"
        }
      ]
    }
  5. 5

    Extract a reusable validate middleware

    Putting safeParse calls inline in every controller is repetitive. Write a higher-order middleware factory that takes a schema and returns a middleware — routes stay clean and validation is declarative.

    typescript
    // src/middleware/validate.ts
    import { ZodSchema } from "zod"
    import { Request, Response, NextFunction } from "express"
    
    export function validate(schema: ZodSchema) {
      return (req: Request, res: Response, next: NextFunction) => {
        const result = schema.safeParse(req.body)
        if (!result.success) {
          return res.status(422).json({
            error: "Validation failed",
            issues: result.error.issues.map((issue) => ({
              field: issue.path.join("."),
              message: issue.message,
            })),
          })
        }
        req.body = result.data // replace raw body with parsed + sanitised data
        next()
      }
    }
    
    // Usage in a route file:
    import { validate } from "../middleware/validate"
    import { createUserSchema } from "../schemas/user.schema"
    
    router.post("/", validate(createUserSchema), usersController.create)
  6. 6

    Sanitise strings inside the schema

    Zod transforms run during parsing, so sanitisation is built into the schema — not scattered across controllers. Trim whitespace, normalise case, and enforce length limits in one place. The value in req.body after safeParse is already clean.

    typescript
    export const createPostSchema = z.object({
      title: z
        .string()
        .min(5, "Title must be at least 5 characters")
        .max(200, "Title cannot exceed 200 characters")
        .trim(),                    // strip leading/trailing whitespace
    
      slug: z
        .string()
        .toLowerCase()              // normalise before storing
        .regex(/^[a-z0-9-]+$/, "Slug can only contain lowercase letters, numbers, and hyphens"),
    
      content: z
        .string()
        .min(20)
        .max(50_000),               // prevent megabyte-sized payloads
    
      published: z.boolean().default(false),
    
      tags: z
        .array(z.string().trim().max(50))
        .max(10, "No more than 10 tags allowed")
        .default([]),
    })
  7. 7

    Validate route params and query strings

    Zod is not limited to request bodies. Validate route parameters (which are always strings in Express) and query strings the same way. Cast types explicitly — req.params.id comes in as a string and must be coerced to a number.

    typescript
    // Validate route params
    const idParamSchema = z.object({
      id: z.coerce.number().int().positive(), // coerces "42" → 42
    })
    
    // Validate query strings
    const listQuerySchema = z.object({
      page: z.coerce.number().int().positive().default(1),
      limit: z.coerce.number().int().min(1).max(100).default(20),
      search: z.string().trim().max(100).optional(),
    })
    
    // validate middleware extended for params / query:
    export function validateParams(schema: ZodSchema) {
      return (req: Request, res: Response, next: NextFunction) => {
        const result = schema.safeParse(req.params)
        if (!result.success) {
          return res.status(400).json({ error: "Invalid URL parameter", issues: result.error.issues })
        }
        req.params = result.data
        next()
      }
    }

Tips & gotchas

  • Use `z.coerce.number()` instead of `z.number()` for any value that might come in as a string (route params, query strings). It converts `"42"` to `42` before validating.
  • Put your schemas in a `src/schemas/` folder and import them into both the route handlers and your tests. A schema doubles as documentation of the expected API contract.
  • Use `z.discriminatedUnion()` when a request body can have different shapes depending on a `type` field — it's faster and gives better error messages than a plain `z.union()`.
  • `result.error.flatten()` is a convenient alternative to `.issues` that groups errors by field: `{ fieldErrors: { email: ["Invalid email"] }, formErrors: [] }` — useful for form validation responses.

Wrapping up

Zod turns input validation from an afterthought into a first-class part of the API contract. Define the schema once, get runtime safety and TypeScript types for free, and handle errors in a single middleware instead of scattering checks across every handler. Combined with the validate middleware pattern, every route in the project gets consistent, structured error responses with minimal per-route code.

#Node.js #Zod #Validation
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.