Last updated: 2026-09-23

Routing

3.1 What Routing Actually Means

A route is a rule. It says: when a request arrives with this method and this URL path, run this code.

That's the whole concept. But the way Express implements it — and the flexibility it offers — is where things get interesting.

The Express documentation defines routing as "how an application's endpoints (URIs) respond to client requests" . In practice, you're mapping HTTP methods (GET, POST, PUT, DELETE) and URL patterns to handler functions that produce responses.

Here's the simplest possible route:

app.get('/', (req, res) => {
  res.send('hello world');
});

When Express receives a GET request for the root path, it runs that callback. The callback receives the request and response objects, and it sends something back. That's routing in its most basic form .

But real applications don't have one route. They have dozens. They have dynamic paths. They have routes that need to run multiple functions in sequence. They have routes grouped by resource, mounted under prefixes, organized into modules.

That's where Express's routing system shows its depth.

3.2 Route Methods

Express gives you a method for every HTTP verb: app.get(), app.post(), app.put(), app.delete(), app.patch(), and so on .

The method determines when the handler runs. A GET handler only responds to GET requests. A POST handler only responds to POST requests. Same path, different methods — different handlers.

app.get('/users', (req, res) => {
  res.send('Fetching users');
});

app.post('/users', (req, res) => {
  res.send('Creating a user');
});

These are two separate routes, even though they share the same path. Express distinguishes them by method .

There's also app.all(), which is a special method that runs for every HTTP method on a given path. This is useful for middleware that should apply regardless of how the client is talking to your server .

app.all('/secret', (req, res, next) => {
  console.log('Accessing the secret section...');
  next(); // Pass control to the next handler
});

A request to /secret using GET, POST, PUT, DELETE — any method — will trigger that handler first.

The Full Method List

Express supports every HTTP method that Node's http module supports. The commonly used ones:

MethodPurpose
GETRetrieve data
POSTCreate data
PUTReplace data
PATCHPartially update data
DELETERemove data

The less common ones (HEAD, OPTIONS, TRACE, CONNECT) also have corresponding Express methods, though you'll rarely define routes for them directly .

3.3 Route Paths

The path is the URL pattern that Express matches against. It can be a string, a string pattern, or a regular expression.

String Paths

The simplest form. These match the literal path exactly.

app.get('/about', (req, res) => {
  res.send('About page');
});

app.get('/contact', (req, res) => {
  res.send('Contact page');
});

String Patterns

Express also supports pattern matching within string paths, though this has changed significantly in Express 5 (we'll get to that).

In Express 4, you could use characters like ?, +, *, and () to create flexible patterns:

// Express 4: matches /abcd, /abxcd, /abRANDOMcd, etc.
app.get('/ab?cd', (req, res) => { ... });

// Express 4: matches /abe or /abcde
app.get('/ab(cd)?e', (req, res) => { ... });

Regular Expressions

For the most precise control, you can pass a RegExp object directly:

app.get(/.*fly$/, (req, res) => {
  res.send('Matches any path ending in "fly"');
});

This route matches /butterfly, /dragonfly, but not /flytrap. Regular expression paths are powerful but harder to read, so they're best reserved for cases where string patterns can't express what you need.

3.4 Route Parameters

Static paths have limits. You need /users/1, /users/2, /users/42 — not a separate route for every ID.

Route parameters solve this. A parameter is a named segment in the path, prefixed with a colon. When a request arrives, Express captures whatever value occupies that position and stores it in req.params .

app.get('/users/:userId', (req, res) => {
  res.send(`User ID: ${req.params.userId}`);
});

A request to /users/42 gives you req.params.userId === '42'. A request to /users/john gives you 'john'. The value is always captured as a string, regardless of what the client sent .

Multiple parameters work the same way:

app.get('/users/:userId/posts/:postId', (req, res) => {
  const { userId, postId } = req.params;
  res.send(`Post ${postId} by user ${userId}`);
});

A request to /users/42/posts/123 gives you both values in req.params .

The Parameter Name Matters

The name you give a parameter becomes the key in req.params. If you define :userId, you access req.params.userId. If you define :id, you access req.params.id. There's no automatic aliasing.

The Express 5 Change: No More Optional Parameters

Here's where version awareness becomes critical.

In Express 4, you could make a parameter optional by appending ?:

// Express 4 only
app.get('/directory/:foldername?', (req, res) => {
  const folder = req.params.foldername || 'default';
  // ...
});

In Express 5, this syntax no longer works. The new path-to-regexp library that powers Express 5 rejects ? at the end of a parameter and throws a PathError at startup .

The new way to express optional segments uses curly braces:

// Express 5
app.get('/directory{/:foldername}', (req, res) => {
  const folder = req.params.foldername || 'default';
  // ...
});

The {/:foldername} syntax means "this segment is optional." If the client visits /directory, req.params.foldername is undefined. If they visit /directory/myfolder, it's 'myfolder' .

This is a breaking change that will silently break any Express 4 application using optional parameters. It's one of the most common migration pitfalls.

Wildcards in Express 5

Wildcards have also changed. In Express 4, you could use * as an unnamed wildcard:

// Express 4: captures everything into req.params[0]
app.get('/*', (req, res) => { ... });

Express 5 requires wildcards to be named:

// Express 5: must be named
app.get('/*splat', (req, res) => {
  // req.params.splat contains the matched path
});

And there's a subtle behavior change: wildcard parameters in req.params are now always arrays of decoded segments, not single strings. A path like /a%2fb/c (where %2f is an encoded slash) produces req.params.splat === ['a/b', 'c'] in Express 5, whereas Express 4 would give you a single string .

If you need to match the root path as well, you wrap the wildcard in braces:

app.get('/{*splat}', (req, res) => {
  // Matches / and everything below it
});

This is documented in the migration guide, but the req.params behavior change for wildcards is not — it's an undocumented difference that caught developers by surprise .

3.5 Multiple Handlers

A single route can have multiple handler functions. They run in sequence, each receiving the same req and res, each deciding whether to pass control forward.

This is just middleware, applied at the route level.

app.get('/protected',
  (req, res, next) => {
    console.log('First handler: checking auth');
    if (!req.user) {
      return res.status(401).send('Unauthorized');
    }
    next(); // Pass control
  },
  (req, res) => {
    res.send('Welcome, authenticated user');
  }
);

The first handler runs, checks something, and either sends a response or calls next(). If it calls next(), the second handler runs .

You can provide handlers as separate arguments, as an array, or as a combination of both:

const middleware1 = (req, res, next) => { ... };
const middleware2 = (req, res, next) => { ... };

app.get('/route', middleware1, middleware2, (req, res) => {
  res.send('Done');
});

// Or as an array
app.get('/route', [middleware1, middleware2], (req, res) => {
  res.send('Done');
});

The order of execution is the order of definition. Always.

next('route'): Skipping the Rest

There's a special value you can pass to next(): the string 'route'. When called inside a route handler, it skips all remaining handlers for that specific route and moves to the next matching route .

This is useful for preconditions. If a condition isn't met, you don't want to run the rest of this route's handlers — but you also don't want to send an error. You want Express to try the next route.

app.get('/user/:id',
  (req, res, next) => {
    if (!isValidId(req.params.id)) {
      return next('route'); // Skip to the next matching route
    }
    next(); // Continue with this route
  },
  (req, res) => {
    res.send('Valid user ID');
  }
);

app.get('/user/:id', (req, res) => {
  res.send('Fallback: invalid user ID handling');
});

A request with an invalid ID skips the first route's second handler and lands on the fallback route .

3.6 app.route(): Chaining Methods

If you have multiple HTTP methods for the same path, repeating the path gets tedious:

app.get('/user', (req, res) => { res.send('Get user'); });
app.post('/user', (req, res) => { res.send('Create user'); });
app.put('/user', (req, res) => { res.send('Update user'); });
app.delete('/user', (req, res) => { res.send('Delete user'); });

app.route() lets you define the path once and chain the methods:

app.route('/user')
  .get((req, res) => { res.send('Get user'); })
  .post((req, res) => { res.send('Create user'); })
  .put((req, res) => { res.send('Update user'); })
  .delete((req, res) => { res.send('Delete user'); });

This is particularly useful for RESTful APIs, where a single resource path handles multiple operations. The path is defined in one place, and all methods for that path are grouped together .

The chaining pattern reduces redundancy and makes the API's surface area clearer at a glance .

3.7 express.Router: Modular Routing

As an application grows, defining every route on the main app object becomes unmanageable. A single app.js file with fifty routes is hard to navigate and harder to maintain.

The Router object solves this. It's a "mini-application" — a complete middleware and routing system that can be defined in isolation and then mounted onto the main application .

Creating and Mounting a Router

// routes/users.js
const express = require('express');
const router = express.Router();

router.get('/', (req, res) => {
  res.send('User list');
});

router.get('/:id', (req, res) => {
  res.send(`User ${req.params.id}`);
});

router.post('/', (req, res) => {
  res.status(201).send('User created');
});

module.exports = router;
// app.js
const express = require('express');
const userRouter = require('./routes/users');

const app = express();

app.use('/users', userRouter);

Now requests to /users and /users/:id are handled by the router. But notice: the router doesn't know about the /users prefix. It defines routes for / and /:id. The prefix is applied when the parent mounts it .

This separation is the point. The router module is self-contained. It can be moved, reused, or tested in isolation. The prefix is a mounting decision, not a route definition.

Routers Are Middleware

A router behaves exactly like middleware. You mount it with app.use(), and it processes requests that match its mount path. Routers can be nested inside other routers, building a tree of route handlers .

const apiRouter = express.Router();
const v1Router = express.Router();

v1Router.get('/status', (req, res) => {
  res.json({ version: 1, status: 'ok' });
});

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

// GET /api/v1/status now works

This recursive composition is what makes Express's routing system scale. You build small, focused routers and compose them into larger structures .

Router-Level Middleware

A router can have its own middleware, applied only to routes within that router:

router.use((req, res, next) => {
  console.log('Time:', Date.now());
  next();
});

router.use((req, res, next) => {
  // Authentication check for all routes in this router
  if (!req.headers.authorization) {
    return res.status(401).send('Unauthorized');
  }
  next();
});

This middleware runs for every request that enters the router, before any route handler. It's scoped — the main application isn't affected .

router.param(): Parameter Middleware

Routers have a method that applications don't: router.param(). It lets you define a callback that runs whenever a specific parameter appears in a route path .

router.param('userId', (req, res, next, id) => {
  console.log(`User ID parameter detected: ${id}`);
  req.user = { id, name: 'John Doe' };
  next();
});

router.get('/users/:userId', (req, res) => {
  res.send(`User: ${req.user.name}`); // req.user already populated
});

The callback receives the standard req, res, and next, plus the value of the parameter and the name of the parameter. It runs only once per request, even if the parameter appears in multiple matching routes .

This is useful for loading data based on route parameters — fetching a user from a database, validating an ID format, or attaching related data to the request. It keeps that logic out of individual route handlers and applies it consistently wherever the parameter is used.

Important nuance: Param callbacks are local to the router they're defined on. They are not inherited by mounted routers, and they don't fire for parameters defined on parent routers. Each router manages its own parameter triggers .

3.8 Route Matching Order

Express matches routes in the order they're defined. This is fundamental and worth internalizing.

app.get('/users/:id', (req, res) => {
  res.send(`User ${req.params.id}`);
});

app.get('/users/me', (req, res) => {
  res.send('Current user');
});

A request to /users/me will hit the first route. req.params.id will be 'me', and the response will be "User me" — not "Current user."

The first matching route wins. Always. If you want /users/me to be handled specially, define it before the parameterized route:

app.get('/users/me', (req, res) => {
  res.send('Current user');
});

app.get('/users/:id', (req, res) => {
  res.send(`User ${req.params.id}`);
});

Now /users/me matches the specific route first, and /users/42 falls through to the parameterized one.

This ordering behavior applies at every level — within a router, across mounted routers, everywhere. It's the same middleware pipeline logic from Topic 2, applied to route matching.