Express Router doesn’t just match paths; it matches layers of middleware and route handlers based on a combination of path and HTTP method.

Let’s watch this in action.

Consider this simple Express app:

const express = require('express');
const app = express();
const port = 3000;

const apiRouter = express.Router();

apiRouter.use('/users', (req, res, next) => {
  console.log('API Router: Users middleware');
  next();
});

apiRouter.get('/users/:id', (req, res) => {
  console.log('API Router: GET /users/:id handler');
  res.send(`User ID: ${req.params.id}`);
});

app.use('/api', apiRouter);

app.get('/', (req, res) => {
  console.log('App: GET / handler');
  res.send('Hello World!');
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

When you send a GET request to /api/users/123, here’s what happens under the hood:

  1. Request Arrives: The incoming HTTP request for GET /api/users/123 hits the Express app.
  2. App’s Middleware Stack: Express iterates through its top-level middleware and route handlers.
  3. app.use('/api', apiRouter) Match: The path /api/users/123 starts with /api. This triggers the middleware mounted at /api, which is our apiRouter.
  4. Router’s Internal Stack: Now, control is handed to apiRouter. It begins iterating through its own internal stack of middleware and route handlers, but with a crucial difference: all paths within the router are implicitly prefixed by the path it was mounted at (/api).
  5. apiRouter.use('/users', ...) Match: The apiRouter checks its middleware. The current path, relative to the router’s mount point, is /users/123. The middleware apiRouter.use('/users', ...) matches this because /users/123 starts with /users. The console.log('API Router: Users middleware'); line executes. next() is called.
  6. apiRouter.get('/users/:id', ...) Match: The router continues. The path /users/123 is then checked against its defined routes. apiRouter.get('/users/:id', ...) matches because it’s a GET request, and /users/123 matches the pattern /users/:id. The console.log('API Router: GET /users/:id handler'); line executes. The response is sent.

The key insight is that app.use('/api', apiRouter) doesn’t just pass the request to the router; it effectively "prepends" /api to every path defined within the apiRouter for matching purposes. When the router processes its own handlers, it’s working with a path that’s already been filtered by its parent.

This layering is how Express builds complex routing structures. A router can contain other routers, creating a tree of middleware and handlers. Each router.use() and router.METHOD() call adds an entry to an internal, ordered list. When a request comes in, Express (or the parent router) walks this list. For router.use(path, handler) it checks if the remaining path starts with path. If it does, it calls handler and passes the remaining path (after stripping path) along to the next layer. For router.METHOD(path, handler), it checks if the remaining path exactly matches path (or a pattern if it’s a route parameter) and if the HTTP method matches.

The most surprising thing about this system is how router.use() and router.METHOD() handlers are processed. It’s not a simple "first match wins" for the entire application. Instead, Express maintains separate internal lists for middleware (use) and specific HTTP methods (get, post, etc.) within each router. When a request enters a router, it first iterates through all registered router.use() middleware that match the current path. Only after all matching use middleware have been executed (and if next() was called in each) does Express then look for a specific route handler (router.get, router.post, etc.) that matches both the method and the path. This means a router.use() can intercept and modify a request or even send a response before any specific route handler for that path is ever considered.

If you were to then add app.get('/api/other', (req, res) => res.send('Other API endpoint'));, a GET /api/other request would bypass the apiRouter entirely because the app.use('/api', apiRouter) middleware would match and pass control to the apiRouter. However, the apiRouter has no handler for /other (relative to its /api prefix), so the request would fall through within the router. Since the router has no other matching handlers, control would then return to the app’s top-level handlers. Because app.get('/api/other', ...) is defined after app.use('/api', apiRouter), and the apiRouter didn’t handle it, the app.get('/api/other', ...) would then match and execute.

You’ll next encounter the nuances of how Express handles unmatched routes and the default 404 behavior.

Want structured learning?

Take the full Express course →