Express Router: A complete reference for Node.js developers

When an Express app has groups of related routes, such as everything under a project’s URL, you can keep each group in its own module. An Express Router holds those routes and their middleware, and the app mounts it under a path prefix.

This reference walks through building a Router module and mounting it, including routes nested under a parent.

What is an Express Router?

An Express Router is a mountable routing and middleware system that groups handlers under a path prefix. The application mounts it, and the Router matches paths relative to that mount.

A Router does not make a small app faster or change what an endpoint can return. Keep a direct app.get route when it is self-contained, and use a Router when related endpoints need their own prefix, middleware, or module.

For example, a child Router can define a route for a project ID while its parent supplies the account and project path segments.

ObjectWhat it ownsTypical job
Express appApplication-wide middleware and mounted routersStart the server and connect route groups
RouterRoutes and middleware for one featureDefine project paths and their handlers
Mount pathThe prefix added before a child routePlace the project routes under an account URL

What you need before mounting a router

Express 5 requires Node.js 18 or newer, and the example uses JavaScript module files ending in .mjs. The Express 5 API reference documents the runtime requirement.

If you still need a Node project, the site’s Node.js tutorial covers the initial setup.

  • Node.js 18 or newer, as required by Express 5.
  • A Node project. Run npm init -y when the directory does not already have a package file.
  • Express installed from the project root with npm install express.
  • Use three module files ending in .mjs for the Router, app, and verification program.

Step 1: Define routes in a Router module

A feature Router can own its middleware, parameter lookup, and route methods. This child Router uses mergeParams so handlers can read the accountId supplied by the parent mount.

import express from 'express';

const router = express.Router({ mergeParams: true });
const projects = new Map([
  ['42', { id: '42', name: 'Router demo' }],
]);

router.use((req, res, next) => {
  res.set('X-Route-Group', 'projects');
  next();
});

router.param('projectId', (req, res, next, projectId) => {
  const project = projects.get(projectId);
  if (!project) {
    return res.status(404).json({ error: 'Project not found' });
  }
  req.project = project;
  next();
});

router.route('/')
  .get((req, res) => {
    res.json({ accountId: req.params.accountId, projects: [...projects.values()] });
  })
  .post((req, res) => {
    res.status(201).json({ accountId: req.params.accountId, created: req.body.name });
  });

router.get('/search/:term', (req, res) => {
  res.json({ term: req.params.term });
});

router.get('/assets{/*assetPath}', (req, res) => {
  res.json({ assetPath: req.params.assetPath ?? [] });
});

router.get('/documents/:name{.:extension}', (req, res) => {
  res.json({ name: req.params.name, extension: req.params.extension ?? null });
});

router.get('/fail', async (req, res) => {
  throw new Error('Demonstration failure');
});

router.route('/:projectId')
  .get((req, res) => {
    res.json({ accountId: req.params.accountId, project: req.project });
  })
  .patch((req, res) => {
    res.json({ updated: req.project.id });
  })
  .delete((req, res) => {
    res.sendStatus(204);
  });

export default router;

The GET handlers return JSON with res.json(). The existing guide to sending JSON responses with Express covers response formatting separately.

The router.param callback runs when a matching route includes projectId, so a missing project can stop with a 404 before the route handler runs. The return before that response matters because it prevents the callback from continuing into next().

router.route(‘/’) groups GET and POST handlers for the same local path, while router.route(‘/:projectId’) groups operations on one project. If handlers grow into database operations, keep these route bindings small and move that work into controller functions, as in the site’s RESTful API with Node.js and Express walkthrough.

Step 2: Read the path Express matches

Express combines each mount path with the child Router’s local path. Route parameters capture path segments as strings, while query-string values are separate from req.params.

Child pathRequest under the mountsResult
/:projectIdGET /api/v1/accounts/acme/projects/42accountId is acme and projectId is 42
/search/:termGET /api/v1/accounts/acme/projects/search/routerterm is router
/assets{/*assetPath}GET /api/v1/accounts/acme/projects/assetsassetPath is an empty array
/assets{/*assetPath}GET /api/v1/accounts/acme/projects/assets/icons/logo.svgassetPath contains icons and logo.svg
/documents/:name{.:extension}GET /api/v1/accounts/acme/projects/documents/report.jsonname is report and extension is json

The accountId parameter comes from the parent path, so the child Router needs mergeParams: true to see it. The Express 5 routing guide describes how mounted routers and path parameters combine.

Step 3: Mount the Router under its resource path

The app mounts an accounts Router under /api/v1/accounts, then that parent mounts the projects Router under /:accountId/projects. Together with the child’s /:projectId path, the full request URL is /api/v1/accounts/acme/projects/42.

import express from 'express';
import projectsRouter from './routes/projects.mjs';

const app = express();
const accountsRouter = express.Router();

app.use(express.json());
accountsRouter.use('/:accountId/projects', projectsRouter);
app.use('/api/v1/accounts', accountsRouter);

app.use((req, res) => {
  res.status(404).json({ error: 'No route matches this request' });
});

app.use((err, req, res, next) => {
  if (res.headersSent) return next(err);
  res.status(500).json({ error: 'Request failed' });
});

export default app;

express.json() runs before the Router mount so a POST handler can read req.body. An unmatched request then reaches the 404 middleware, while an error passed with next(err) or rejected by an async handler reaches the four-argument error middleware.

Keep the 404 response and error response separate because they represent different control-flow paths. The site’s guide to error handling in Express middleware goes deeper into that boundary.

Step 4: Keep middleware and route order deliberate

Express processes middleware and routes in registration order, so each handler must respond, call next(), or pass an error onward.

  • Use router.use() for middleware shared by a Router, or pass middleware directly to a route that needs it.
  • Call next() to continue through the normal handler chain. A handler that neither responds nor calls next() leaves the request without a response.
  • Put fixed paths such as /search/router before a broad parameter route such as /:projectId when the paths can overlap.
  • Use next(‘route’) to skip the remaining callbacks for the current route, or next(err) to enter error handling.

Middleware passed to router.use() is not the same as a route callback, so next(‘route’) is not its escape hatch. The Express middleware guide explains the router-level chain and its mount paths.

Step 5: Handle Express 5 paths and errors

Express 5 uses named wildcards and brace-delimited optional path portions. These rules matter when a route string is copied from an Express 4 example.

BehaviorExpress 5 formWhat the handler receives
One named path segment/:projectIdreq.params.projectId is a string
Wildcard after a required prefix/files/*filepathfilepath is an array of remaining path segments
Wildcard that can include the base path/files{/*filepath}The wildcard may be absent on /files and is an array when present
Optional extension/:name{.:extension}extension is omitted when the dot and suffix are absent
Router optionsmergeParams, caseSensitive, strictParent params are not merged by default, matching is case-insensitive and trailing slashes are accepted by default
Rejected promise from an async handlerExpress 5 forwards the rejection to error middlewareThe error handler receives the failure without a manual try/catch in the route

The Router API lists mergeParams, caseSensitive, and strict as false by default. Enable mergeParams when a child needs a parent parameter, and change caseSensitive or strict only when that matching behavior is part of the route contract. See the Express 5 Router API.

Express 5 wildcard names are required, and wildcard values arrive as arrays. To match both /files and paths below it, keep the slash inside the optional braces, as in /files{/*filepath}; the Express 5 migration guide documents these path changes.

In the tested Router, /assets{/*assetPath} matched both /assets and /assets/icons/logo.svg. I replaced the braces after the first form failed to match the bare base path, and the final run returned an empty array for /assets and two segments for the nested URL.

The mount point becomes part of the public URL

A route’s public URL is the combined path from each mount and the child route, so keep a feature’s prefix in one mount declaration instead of repeating it in every handler.

I ran node verify.mjs with Express 5.2.1 on Node 26.10.0. The nested project request returned 200, an unknown project and an unknown URL each returned 404, and a rejected async handler reached the 500 error middleware.

Save this as verify.mjs beside app.mjs. It sends successful, missing-project, async-error, and missing-route requests, then closes the server.

import app from './app.mjs';

const server = app.listen(0, '127.0.0.1');
await new Promise((resolve) => server.once('listening', resolve));
const { port } = server.address();
const base = `http://127.0.0.1:${port}`;
const requests = [
  { method: 'GET', path: '/api/v1/accounts/acme/projects/42' },
  { method: 'POST', path: '/api/v1/accounts/acme/projects', body: { name: 'CLI project' } },
  { method: 'GET', path: '/api/v1/accounts/acme/projects/search/router' },
  { method: 'GET', path: '/api/v1/accounts/acme/projects/assets/icons/logo.svg' },
  { method: 'GET', path: '/api/v1/accounts/acme/projects/assets' },
  { method: 'GET', path: '/api/v1/accounts/acme/projects/documents/report' },
  { method: 'GET', path: '/api/v1/accounts/acme/projects/documents/report.json' },
  { method: 'GET', path: '/api/v1/accounts/acme/projects/404' },
  { method: 'GET', path: '/api/v1/accounts/acme/projects/fail' },
  { method: 'GET', path: '/missing' },
];

try {
  for (const { method, path, body } of requests) {
    const options = { method };
    if (body) {
      options.headers = { 'content-type': 'application/json' };
      options.body = JSON.stringify(body);
    }
    const response = await fetch(`${base}${path}`, options);
    const result = await response.text();
    console.log(`${response.status} ${method} ${path} ${result}`);
  }
} finally {
  await new Promise((resolve, reject) => {
    server.close((error) => error ? reject(error) : resolve());
  });
}
Express Router test output showing mounted routes, matching results, and error responses
The tested Express 5 app returns nested project data, an optional wildcard result, route-local and app-level 404 responses, and a forwarded async error.

Run node verify.mjs after changing a mount path or route, then compare the returned status with the URL you meant to expose.

node verify.mjs

Keep that prefix in one mount declaration, and the child route stays reusable under another resource path.

Frequently asked questions about Express Router

How is express.Router different from app.get?

app.get() registers a route on the application. express.Router() creates a mountable route and middleware stack that you attach with app.use(), which lets you group related endpoints under a prefix.

How does a child Router read parent route parameters?

Create the child Router with mergeParams: true. Without that option, a parameter such as accountId from the parent mount is not merged into the child Router’s req.params.

How do wildcard routes work in Express 5?

Give the wildcard a name, such as *filepath, and Express puts the captured path segments in an array. Wrap the slash and wildcard in braces when the route should also match its base path.

Does Express 5 forward async route errors?

Yes. Express 5 forwards a rejected promise from a route handler to error-handling middleware, so the route does not need its own try/catch solely to call next(err).

Aditya Gupta
Aditya Gupta

Aditya Gupta is a founding member and editor at CodeForGeek. He first found his way into tech by reading articles, and now writes approachable guides to Node.js security, authentication, AI tools, coding agents, and web scraping.

Articles: 529