Express Routing
Express routing is the system that maps an incoming HTTP request — a method plus a URL path — to the function that should handle it. Instead of writing one giant if/else chain based on req.url, Express lets you declare routes like app.get("/users/:id", handler) and it takes care of matching the path, extracting parameters, and running your code in order. As an API grows to dozens or hundreds of endpoints, routing is what keeps it organized: routers group related endpoints into their own modules, middleware runs before or after handlers, and route parameters and query strings give handlers access to the data they need. This lesson covers everything from a single app.get() call to modular routers, chained routes, and the request-matching internals that make it all work, targeting Express 5.
Overview: How Express Routing Works
Every Express app keeps an internal, ordered list of "layers." Each layer pairs a path pattern (and usually an HTTP method) with a function. When app.listen() starts the server, Express registers a single request listener on Node’s http.Server; every request that arrives is handed to this listener, which walks the layer list from top to bottom and runs the first layer whose method and path match the request. That handler can end the response, or call next() to let Express continue checking layers below it.
Two kinds of layers matter here. A route is registered with a specific HTTP method — app.get(), app.post(), app.put(), app.patch(), app.delete() — and is expected to finish the request-response cycle by calling res.send(), res.json(), or res.end(). Middleware is registered with app.use(), runs for any method whose path matches, and either calls next() to pass control along or sends a response itself. Because Express checks layers strictly in the order they were registered, order matters enormously: a middleware registered before your routes runs on every request; a route registered after a catch-all handler never runs at all.
Under the hood, Express compiles each path pattern — /users/:id, /orders/:id/items, and so on — into a regular expression using the path-to-regexp library, once, at the moment you call app.get() or similar. When a request comes in, Express tests the request path against these precompiled patterns in registration order. The first match wins; its named segments (:id) are extracted into req.params. The query string is parsed separately into req.query regardless of which route matched — in Express 5 the default query parser is "simple" (backed by Node’s built-in querystring module) rather than Express 4’s "extended" parser (backed by the qs package), which is a real behavioral difference worth knowing about if you are used to older Express apps.
Routers as mini-applications
express.Router() creates an isolated, mountable set of routes and middleware with the same API as app itself. Mounting a router with app.use("/api/users", router) prefixes every route inside it with /api/users, which is what lets large APIs be split into one file per resource instead of one enormous app.js.
Syntax
app.METHOD(path, [middleware...,] handler) // GET, POST, PUT, PATCH, DELETE, ALL
app.use([path,] middleware) // runs for every method
app.route(path).get(h1).post(h2).delete(h3) // chain methods for one path
const router = express.Router([options]); // modular, mountable routes
app.use(mountPath, router);
| Part | Meaning |
|---|---|
path |
A string pattern such as "/users/:id". Segments starting with : are named parameters captured into req.params. |
handler |
A function (req, res[, next]) that reads the request and sends a response. |
req.params |
An object of named path segments, e.g. { id: "42" } for /users/:id matched against /users/42. Always strings. |
req.query |
An object parsed from the URL’s query string, e.g. ?limit=10 becomes { limit: "10" }. Always strings unless you enable the extended parser. |
next |
A function that hands control to the next matching layer. Omit it in a route handler that always ends the response. |
Examples
The simplest form of routing calls a method directly on the app object. Each call registers one route: a method, a path, and a handler.
npm install express
import express from "express";
const app = express();
app.use(express.json());
app.get("/", (req, res) => {
res.send("Welcome to the API");
});
app.get("/users/:id", (req, res) => {
const { id } = req.params;
res.json({ id, name: "Ada Lovelace" });
});
app.post("/users", (req, res) => {
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: "name is required" });
}
res.status(201).json({ id: 42, name });
});
const port = process.env.PORT ?? 3000;
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
GET / -> 200 "Welcome to the API"
GET /users/7 -> 200 {"id":"7","name":"Ada Lovelace"}
POST /users {} -> 400 {"error":"name is required"}
POST /users {"name":"Grace"} -> 201 {"id":42,"name":"Grace"}
Notice that req.params.id is the string "7", not the number 7 — route parameters are always strings, so convert them yourself when you need a number. The express.json() middleware must run before any route that reads req.body, which is another reason registration order matters.
Real APIs split routes across files with express.Router(). Each router behaves like a small app; you mount it under a prefix in your main file.
// routes/users.js
import { Router } from "express";
const router = Router();
router.get("/", (req, res) => {
res.json({ users: [] });
});
router.get("/:id", (req, res) => {
res.json({ id: req.params.id });
});
router.post("/", (req, res) => {
const { name } = req.body ?? {};
if (!name) {
return res.status(400).json({ error: "name is required" });
}
res.status(201).json({ created: true, name });
});
export default router;
// app.js
import express from "express";
import usersRouter from "./routes/users.js";
const app = express();
app.use(express.json());
app.use("/api/users", usersRouter);
app.listen(3000, () => {
console.log("Server listening on port 3000");
});
Because the router is mounted at /api/users, its own routes stay simple — router.get("/:id", ...) inside the file actually answers GET /api/users/42. This is the pattern used to organize any nontrivial Express app: one router module per resource.
When several HTTP methods share the same path, app.route() lets you chain them instead of repeating the path string, and a final catch-all middleware handles anything no route matched.
import express from "express";
const app = express();
app.route("/articles/:id")
.get((req, res) => {
res.json({ id: req.params.id, action: "read" });
})
.put((req, res) => {
res.json({ id: req.params.id, action: "update" });
})
.delete((req, res) => {
res.json({ id: req.params.id, action: "delete" });
});
app.get("/search", (req, res) => {
const { q, limit = "10" } = req.query;
res.json({ query: q, limit: Number(limit) });
});
app.use((req, res) => {
res.status(404).json({ error: "Not found" });
});
app.listen(3000, () => {
console.log("Server listening on port 3000");
});
The final app.use() has no path, so it matches any request that fell through every earlier route — that only works because it is registered last. Query values like limit always arrive as strings, so the handler explicitly converts it with Number() before using it.
Under the Hood: How a Request Finds Its Route
Walking through PUT /articles/42 against the third example above shows the order of operations precisely:
1. Node’s http.Server receives the raw request and calls the single listener Express registered in app.listen().
2. Express builds req and res and starts scanning its layer stack from the top.
3. It tests the request path against each precompiled pattern. The layer created by app.route("/articles/:id") matches the path; among its stored methods, the .put() handler matches the request method, so that is the one that runs. req.params.id is set to "42" at match time.
4. The handler calls res.json(...), which sends the response and ends the cycle — no next() is needed because no later layer needs to run.
5. If no route had matched (say, a request to /unknown), Express would keep walking the stack until it reached the final app.use() catch-all, which always matches and returns the 404 JSON body.
Two Express 5 behaviors are worth calling out here because they change how routing feels compared to Express 4. First, if a route handler is an async function and it throws or its returned promise rejects, Express 5 automatically calls next(err) for you, routing the error to your error-handling middleware — you no longer need to wrap every async handler in a manual try/catch just to forward errors. Second, path-to-regexp (the library Express uses to compile patterns) was upgraded to a version with stricter rules: a bare * wildcard is no longer valid on its own and must be named, e.g. /files/*splat, with the captured segments available at req.params.splat.
Common Mistakes
1. Registering the catch-all or 404 handler before your real routes. Because Express checks layers in order, a path-less middleware placed early swallows every request below it.
app.use((req, res) => {
res.status(404).json({ error: "Not found" });
});
app.get("/users/:id", (req, res) => {
res.json({ id: req.params.id });
});
The /users/:id route above is dead code — every request is caught by the middleware first. Fix it by registering routes first and the catch-all last:
app.get("/users/:id", (req, res) => {
res.json({ id: req.params.id });
});
app.use((req, res) => {
res.status(404).json({ error: "Not found" });
});
2. Assuming nested query strings parse automatically. Express 4 defaulted to the extended query parser (the qs package), which turned ?filter[color]=red into req.query.filter.color. Express 5 defaults to simple parsing, so that same URL leaves req.query.filter undefined.
app.get("/search", (req, res) => {
res.json(req.query.filter.color);
});
That throws a TypeError at runtime once filter is undefined. If your API depends on nested query objects, opt back into extended parsing explicitly:
app.set("query parser", "extended");
app.get("/search", (req, res) => {
res.json(req.query.filter?.color ?? null);
});
3. Carrying over Express 4’s bare wildcard. A route like /files/* worked in Express 4 but throws when Express 5 tries to compile it, because unnamed wildcards were removed from the underlying matching library.
app.get("/files/*", (req, res) => {
res.send(req.params[0]);
});
Name the wildcard instead, and read it off req.params by that name:
app.get("/files/*splat", (req, res) => {
res.send(req.params.splat.join("/"));
});
4. Sending more than one response for the same request. Calling a response method twice throws Error: Cannot set headers after they are sent to the client.
app.get("/users/:id", (req, res) => {
res.json({ id: req.params.id });
res.send("done");
});
Always return from an early response so the function exits immediately afterward:
app.get("/users/:id", (req, res) => {
return res.json({ id: req.params.id });
});
Best Practices
- Group related endpoints with
express.Router()and mount them under a versioned prefix such as/api/v1/users. - Keep route handlers thin: validate input, call a service or model function, then format the response. Push business logic out of the routing layer.
- Register specific routes before generic ones, and always register the 404 handler and the 4-argument error-handling middleware last, in that order.
- Use
router.param()or a small middleware to centralize repeated lookups (like loading a resource by:id) instead of duplicating them in every handler. - Prefer
async/awaitin handlers; Express 5 forwards rejected promises to your error middleware automatically, but still wrap non-request-scoped async work in its own try/catch. - Set
app.set("query parser", "extended")explicitly if your API relies on nested query objects — don’t depend on a framework default that changed between major versions. - Always validate and coerce
req.paramsandreq.querybefore using them; they are always strings, never numbers or booleans. - When nesting routers (a router mounted inside another router’s path), pass
{ mergeParams: true }toexpress.Router()so the inner router can read the outer router’s path parameters.
Practice Exercises
1. Build a productsRouter with express.Router() supporting GET / (list), GET /:id (single item), POST / (create), and DELETE /:id. Mount it at /api/v1/products. Return 400 JSON if a POST is missing a name field, and 404 JSON if :id is not found in your in-memory array.
2. Create a nested router: mount a commentsRouter at /api/v1/posts/:postId/comments, and construct that router with { mergeParams: true } so its GET /:id handler can read both req.params.postId and req.params.id. Confirm both values appear in the JSON response.
3. Add centralized error handling to any app from the exercises above: write a 4-argument error-handling middleware registered after every route, make one route handler throw inside an async function to simulate a failure, and confirm the client receives a JSON error body (for example { "error": "Internal Server Error" }) instead of Express’s default HTML error page.
Summary
- An Express route pairs an HTTP method and a URL path pattern with a handler function; middleware registered with
app.use()runs for any method on a matching path. - Express compiles each path into a regular expression once, via
path-to-regexp, and matches incoming requests against its layer stack strictly in registration order. express.Router()creates modular, mountable route groups; mount them withapp.use(prefix, router), and use{ mergeParams: true }to access parent parameters in nested routers.app.route(path)lets you chain multiple methods for the same path without repeating the path string.- Registration order controls behavior: catch-all handlers and error middleware must be registered last, or they will swallow requests meant for routes below them.
- Express 5 changes real behavior: async rejections are forwarded to error middleware automatically, wildcard routes must be named (
*splat), and the default query parser is"simple"rather than the oldqs-based"extended"parser.
