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
| Source | Example URL / request | Read with | Use for |
|---|---|---|---|
| Route params | /users/42 with path /users/:id | req.params.id → "42" | Identifying a resource |
| Query string | /users?page=2&limit=10 | req.query.page → "2" | Filtering, sorting, paging |
| Body | JSON sent with POST / PUT / PATCH | req.body (needs express.json()) | Data to create or update |
| Headers | Authorization: Bearer ... | req.headers.authorization | Auth tokens, metadata |
Note: params and query values are always strings.
req.params.idis"42", not42. 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.jsconst 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.jsconst 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 onesrouter.get("/users/me", getMyProfile);router.get("/users/:id", getUserById);
Part 2: Request Validation
Why Validate?
Never trust data from the client. Without validation:
| Problem | Example |
|---|---|
| Crashes | req.body.email.toLowerCase() when email is missing |
| Bad data in the database | age: "abc" or age: -5 saved as-is |
| Security issues | Extra fields like isAdmin: true sneaking into an update |
| Confusing errors | A 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 firststripUnknown: true, // remove fields not in the schema});if (error) {console.log(error.details.map((d) => d.message));}
Two important differences in defaults:
| Joi | Zod | |
|---|---|---|
| Fields | Optional unless you add .required() | Required unless you add .optional() |
| Unknown fields | Error 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.jsfunction 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 datanext();};}module.exports = validate;
// routes/users.jsconst validate = require("../middleware/validate");router.post("/", validate({ body: createUserSchema }), (req, res) => {const user = req.validated.body; // safe to useres.status(201).json(user);});router.get("/:id", validate({ params: paramsSchema }), (req, res) => {const { id } = req.validated.params; // a number, not a stringres.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.validatedinstead of overwritingreq.query. In Express 5,req.queryis a getter, so it can't be reassigned.
Joi vs Zod
| Joi | Zod | |
|---|---|---|
| Age | Older, very mature | Newer, very popular |
| TypeScript | Types not inferred from schemas | Infers types from schemas with z.infer |
| Fields by default | Optional | Required |
| Converting strings | Automatic | z.coerce |
| Check without throwing | schema.validate() → { error, value } | schema.safeParse() → { success, data, error } |
| Good fit | JavaScript projects, existing Joi codebases | TypeScript 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, andreq.headers. Params and query are always strings. - Use
express.Routerto split routes into files, and mount them withapp.use("/api/users", router). - Routes match top to bottom, so put specific routes before dynamic ones (
/mebefore/: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
400with 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.