On this page

Routing and Request Validation (Joi/Zod)

Every API request needs to answer two questions: which code should handle it, and is the data it sent valid? Routing answers the first. Request validation answers the second.

Definition

  • Routing: deciding which handler runs for a request, based on its HTTP method (GET, POST, ...) and URL path (/users/5).
  • Request validation: checking that the data in a request (body, params, query) has the right shape and values before your code uses it.
  • Joi and Zod: libraries that let you describe the expected data as a schema, and check requests against it.

Analogy: think of a post office. Routing is sorting each letter into the right bin by its address. Validation is checking that the letter has a stamp, a valid pin code, and a name before it goes anywhere. A letter that fails the check is returned to the sender (a 400 response).

Part 1: Routing

Basic Routes

const express = require("express");
const app = express();
app.use(express.json());
app.get("/users", (req, res) => res.send("List users"));
app.post("/users", (req, res) => res.send("Create user"));
app.get("/users/:id", (req, res) => res.send("Get user " + req.params.id));
app.put("/users/:id", (req, res) => res.send("Replace user"));
app.patch("/users/:id", (req, res) => res.send("Update user"));
app.delete("/users/:id", (req, res) => res.send("Delete user"));
app.listen(3000);

A route matches only when both the method and the path match. GET /users and POST /users are different routes.

Where Request Data Comes From

SourceExample URL / requestRead withUse for
Route params/users/42 with path /users/:idreq.params.id → "42"Identifying a resource
Query string/users?page=2&limit=10req.query.page → "2"Filtering, sorting, paging
BodyJSON sent with POST / PUT / PATCHreq.body (needs express.json())Data to create or update
HeadersAuthorization: Bearer ...req.headers.authorizationAuth tokens, metadata

Note: params and query values are always strings. req.params.id is "42", not 42. Validation is where you convert them.

Organizing Routes With express.Router

As an app grows, keep each resource's routes in its own file:

// routes/users.js
const express = require("express");
const router = express.Router();
router.get("/", (req, res) => res.send("List users"));
router.get("/:id", (req, res) => res.send("Get user " + req.params.id));
router.post("/", (req, res) => res.send("Create user"));
module.exports = router;
// app.js
const usersRouter = require("./routes/users");
app.use("/api/users", usersRouter);
// GET /api/users -> List users
// GET /api/users/42 -> Get user 42

The router's paths are relative to where it is mounted (/api/users).

router.route(): Grouping Methods on One Path

router
.route("/:id")
.get((req, res) => res.send("Get user"))
.patch((req, res) => res.send("Update user"))
.delete((req, res) => res.send("Delete user"));

Route Order Matters

Express checks routes top to bottom and uses the first match.

// Wrong: "/users/me" matches "/users/:id" first, with id = "me"
router.get("/users/:id", getUserById);
router.get("/users/me", getMyProfile); // never reached
// Right: specific routes before dynamic ones
router.get("/users/me", getMyProfile);
router.get("/users/:id", getUserById);

Part 2: Request Validation

Why Validate?

Never trust data from the client. Without validation:

ProblemExample
Crashesreq.body.email.toLowerCase() when email is missing
Bad data in the databaseage: "abc" or age: -5 saved as-is
Security issuesExtra fields like isAdmin: true sneaking into an update
Confusing errorsA 500 error instead of a clear "email is required"

Validate at the edge, as soon as the request arrives, and return a 400 Bad Request with a clear message when it fails.

The Same Schema in Joi and Zod

const Joi = require("joi");
const createUserSchema = Joi.object({
name: Joi.string().trim().min(2).max(50).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(18).optional(),
role: Joi.string().valid("user", "admin").default("user"),
});
const { error, value } = createUserSchema.validate(req.body, {
abortEarly: false, // report all errors, not just the first
stripUnknown: true, // remove fields not in the schema
});
if (error) {
console.log(error.details.map((d) => d.message));
}

Two important differences in defaults:

JoiZod
FieldsOptional unless you add .required()Required unless you add .optional()
Unknown fieldsError by default (use stripUnknown: true to remove them)Removed silently by z.object()

Validating Params and Query

Params and query values are strings, so convert them while validating:

const paramsSchema = Joi.object({
id: Joi.number().integer().positive().required(), // "42" -> 42
});
const querySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(10),
});

Joi converts strings to numbers by default. Zod needs z.coerce.

A Reusable Validation Middleware

Write the check once, and use it on any route:

// middleware/validate.js
function validate(schemas) {
return (req, res, next) => {
const validated = {};
const errors = [];
for (const key of ["params", "query", "body"]) {
if (!schemas[key]) continue;
const result = schemas[key].safeParse(req[key]);
if (result.success) {
validated[key] = result.data;
} else {
for (const issue of result.error.issues) {
errors.push({ field: [key, ...issue.path].join("."), message: issue.message });
}
}
}
if (errors.length > 0) {
return res.status(400).json({ message: "Validation failed", errors });
}
req.validated = validated; // cleaned, converted data
next();
};
}
module.exports = validate;
// routes/users.js
const validate = require("../middleware/validate");
router.post("/", validate({ body: createUserSchema }), (req, res) => {
const user = req.validated.body; // safe to use
res.status(201).json(user);
});
router.get("/:id", validate({ params: paramsSchema }), (req, res) => {
const { id } = req.validated.params; // a number, not a string
res.json({ id });
});

Example error response for POST /api/users with { "name": "A", "email": "abc" }:

{
"message": "Validation failed",
"errors": [
{
"field": "body.name",
"message": "Too small: expected string to have >=2 characters"
},
{ "field": "body.email", "message": "Invalid email address" }
]
}

Note: the validated data is stored on req.validated instead of overwriting req.query. In Express 5, req.query is a getter, so it can't be reassigned.

Joi vs Zod

JoiZod
AgeOlder, very matureNewer, very popular
TypeScriptTypes not inferred from schemasInfers types from schemas with z.infer
Fields by defaultOptionalRequired
Converting stringsAutomaticz.coerce
Check without throwingschema.validate() → { error, value }schema.safeParse() → { success, data, error }
Good fitJavaScript projects, existing Joi codebasesTypeScript projects, sharing schemas with the frontend
const createUserSchema = z.object({
name: z.string(),
email: z.email(),
});
type CreateUser = z.infer<typeof createUserSchema>;
// { name: string; email: string }

Summary

  • Routing picks the handler by HTTP method + path.
  • Data comes from req.params, req.query, req.body, and req.headers. Params and query are always strings.
  • Use express.Router to split routes into files, and mount them with app.use("/api/users", router).
  • Routes match top to bottom, so put specific routes before dynamic ones (/me before /:id).
  • Never trust client data. Validate body, params, and query as soon as the request arrives.
  • Joi and Zod describe the expected data as a schema.
  • Joi fields are optional by default. Zod fields are required by default.
  • Use a reusable validation middleware that returns 400 with field-level errors.
  • Zod is a great fit for TypeScript, because it infers types from schemas.

Interview Answer: How Do You Handle Routing and Request Validation in Express?

For routing, Express matches each request by its HTTP method and path and runs the first matching handler. I read data from req.params for IDs, req.query for filters and paging, and req.body for data to create or update. I group routes per resource with express.Router, mount them under a prefix like /api/users, and put specific routes before dynamic ones, because routes match top to bottom.

For validation, I never trust client data. I define a schema with Joi or Zod for the body, params, and query, and check it in a reusable middleware before the route handler runs. If validation fails, I return 400 Bad Request with a clear error for each field. If it passes, I use the cleaned data, which has unknown fields removed and strings like "42" converted to numbers. I prefer Zod in TypeScript projects because it infers types from the schema, so the validation and the types never get out of sync.

Next TopicREST Design, Status Codes, and Idempotency