Express Router and Project Structure
As an Express application grows past a handful of routes, cramming every app.get and app.post call into one file becomes unmanageable. Express solves this with express.Router, a mini, mountable Express application that groups related routes — and the middleware that applies to them — into their own module. Combined with a sensible project structure, routers let you organize a real API into small, testable files instead of one sprawling app.js. This lesson covers how routers work internally, how to build a multi-file project structure around them, and the mistakes that trip up developers moving from a single-file app.
Overview / How it works
An Express Router is not just a grouping convenience — it is a fully-fledged, isolated instance of Express’s routing and middleware system. Internally, the main app object already contains a router that dispatches incoming requests through a stack of layers: each call to app.get, app.use, or app.post pushes a new layer onto that stack, and a layer can match either an exact path or a path prefix. express.Router() creates a second, independent stack with exactly the same matching and dispatch logic. Because it behaves like a smaller version of the app, a router can have its own middleware, its own routes, and can even contain other routers nested inside it.
You attach a router to the app (or to another router) with app.use(prefix, router). When a request comes in, Express walks its layer stack in the order the layers were registered. If a layer is a mounted router whose prefix matches the start of the request path, Express hands control to that router, strips the matched prefix from what the router sees (available inside the router as req.baseUrl), and lets the router’s own stack decide what happens next by calling next() internally. If nothing inside the router calls res.end() (directly or via res.json/res.send) and no layer matches, control returns to the parent app, which continues checking layers below the router — this is exactly why route order matters and why a catch-all 404 handler must be registered last.
Because a router is just a stack of layers, everything you already know about middleware applies to it: router.use(fn) runs on every request that reaches the router, router.use(path, fn) scopes that middleware to a sub-path, and router.param(name, fn) lets you run shared logic whenever a specific route parameter (like :id) appears in a matched path, before any route handler for that path runs.
Syntax
const router = express.Router([options]);
router.get(path, handler);
router.post(path, handler);
router.use([path], middleware);
router.param(name, callback);
app.use(mountPath, router);
| Part | Meaning |
|---|---|
express.Router(options) |
Creates a new, isolated router instance. options is rarely needed; { mergeParams: true } lets a nested router read params captured by its parent. |
router.METHOD(path, ...handlers) |
METHOD is get, post, put, patch, or delete — registers a route relative to wherever the router is mounted. |
router.use([path], fn) |
Registers middleware that runs for requests reaching the router, optionally scoped to a sub-path. |
router.param(name, fn) |
Runs fn(req, res, next, value) once whenever a route on this router matches a :name parameter. |
app.use(mountPath, router) |
Mounts the router on the app (or another router) so its routes are only reached under mountPath. |
Examples
Example 1: A router in its own module
Instead of defining user routes directly on app, put them in routes/users.js and export the router.
import { Router } from "express";
const router = Router();
const users = [
{ id: 1, name: "Ada Lovelace" },
{ id: 2, name: "Grace Hopper" },
];
router.get("/", (req, res) => {
res.json(users);
});
router.get("/:id", (req, res) => {
const id = Number(req.params.id);
const user = users.find((u) => u.id === id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json(user);
});
router.post("/", (req, res) => {
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: "name is required" });
}
const newUser = { id: users.length + 1, name };
users.push(newUser);
res.status(201).json(newUser);
});
export default router;
Then app.js imports and mounts it under a prefix:
import express from "express";
import usersRouter from "./routes/users.js";
const app = express();
const PORT = process.env.PORT ?? 3000;
app.use(express.json());
app.use("/api/users", usersRouter);
app.use((req, res) => {
res.status(404).json({ error: "Not found" });
});
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: "Internal server error" });
});
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
Output:
Server listening on port 3000
Notice the router itself never mentions /api/users — its routes are defined relative to / and /:id. The prefix lives in exactly one place, the app.use call, so moving the whole resource to a different URL is a one-line change.
Example 2: Router-scoped middleware and router.param
Routers can have their own middleware that only runs for requests reaching that router, and router.param centralizes validation for a route parameter used across multiple handlers.
import { Router } from "express";
const router = Router();
router.use((req, res, next) => {
console.log(`[products] ${req.method} ${req.originalUrl}`);
next();
});
router.param("id", (req, res, next, id) => {
if (!/^\d+$/.test(id)) {
return res.status(400).json({ error: "id must be numeric" });
}
req.productId = Number(id);
next();
});
router.get("/:id", (req, res) => {
res.json({ id: req.productId, name: "Sample product" });
});
export default router;
Output (for GET /api/products/42):
[products] GET /api/products/42
{"id":42,"name":"Sample product"}
The logging middleware fires for every request that reaches this router. The router.param callback runs once before the /:id handler and rejects non-numeric ids with a 400 before any handler-specific logic executes, so the validation lives in one place instead of being repeated in every route.
Example 3: Aggregating routers into a project structure
A real project usually has one router per resource, plus an index.js that combines them so app.js only needs to know about a single top-level router:
project/
├── app.js
├── package.json
└── routes/
├── index.js
├── users.js
└── products.js
import { Router } from "express";
import usersRouter from "./users.js";
import productsRouter from "./products.js";
const router = Router();
router.use("/users", usersRouter);
router.use("/products", productsRouter);
export default router;
app.js then mounts just this one aggregator router:
import express from "express";
import apiRouter from "./routes/index.js";
const app = express();
app.use(express.json());
app.use("/api", apiRouter);
app.listen(3000);
The final URLs are the concatenation of every prefix along the way: /api from app.js, plus /users from routes/index.js, plus whatever the individual route defines — so GET / in routes/users.js becomes GET /api/users for the client. This nesting is exactly how large Express codebases stay organized: adding a new resource means adding one file and one line in routes/index.js, never touching app.js.
How it works step by step
- A request arrives at the server and enters the app’s layer stack from the top.
- Express checks each layer in registration order. Application-level middleware like
express.json()runs first if it was registered before the router. - When a layer matches a mounted router’s prefix, Express strips that prefix, sets
req.baseUrl, and hands the request into the router’s own stack. - Inside the router, its own middleware (
router.use) runs first, then any matchingrouter.paramcallbacks for parameters in the matched path, then the route handler. - The handler ends the response with
res.json(),res.send(), orres.end()— or callsnext()to fall through to the next matching layer, andnext(err)to skip straight to an error-handling middleware. - If no route inside the router matches, control returns to the parent app, which keeps checking layers below the router — including a global 404 handler, if one is registered last.
Common Mistakes
Mistake 1: Forgetting to export the router
A router module that never exports its router instance imports as undefined elsewhere, and app.use("/api/users", undefined) throws at startup.
import { Router } from "express";
const router = Router();
router.get("/", (req, res) => {
res.json({ ok: true });
});
// no export — importing this file gives you `undefined`
Always finish a router module with an explicit export:
export default router;
Mistake 2: Registering the 404 handler before the routers
Because layers run in registration order, a catch-all placed too early swallows every request before the real routers ever see it.
app.use((req, res) => {
res.status(404).json({ error: "Not found" });
});
app.use("/api/users", usersRouter);
Every request — including valid ones for /api/users — now gets a 404, because that layer matches first and ends the response. Mount all routers before the catch-all:
app.use("/api/users", usersRouter);
app.use((req, res) => {
res.status(404).json({ error: "Not found" });
});
A related trap: mixing require and import across route files. Pick one module system for the whole project and use it consistently — mixing them causes confusing SyntaxErrors or undefined exports rather than a clear error.
Best Practices
- One router per resource (
routes/users.js,routes/orders.js) — keep each file focused on a single concern. - Define routes on the router relative to
/, and letapp.use(prefix, router)own the URL prefix in exactly one place. - Use an aggregator (
routes/index.js) soapp.jsstays short and only wires up top-level concerns like JSON parsing, CORS, and error handling. - Put business logic in separate controller functions and keep route files focused on wiring paths to handlers — it makes both easier to unit test.
- Register routers before any catch-all 404 handler, and register the 4-argument error-handling middleware last of all.
- Use
router.paramto centralize parameter validation instead of repeating the same check in every handler that uses that parameter. - Keep a consistent module system (CommonJS or ESM) across every file in the project.
Practice Exercises
- Create a
routes/posts.jsrouter withGET /,GET /:id, andPOST /handlers backed by an in-memory array, then mount it at/api/postsfrom an aggregator router. - Add a
router.param("id", ...)callback to your posts router that returns a400if the id is not numeric, and confirm the individualGET /:idhandler no longer needs to repeat that check. - Deliberately swap the order of your 404 handler and your routers, observe every request returning 404, then fix the ordering and explain in a comment why order matters.
Summary
express.Router()creates an isolated stack of routes and middleware that behaves like a mini Express app.- A router is mounted with
app.use(prefix, router); the prefix lives in one place instead of being repeated in every route path. - Layers — including mounted routers — are checked in registration order, so routers must be mounted before a catch-all 404 handler.
router.usescopes middleware to a router, androuter.paramcentralizes parameter validation.- An
index.jsaggregator router letsapp.jsstay small while the project scales to many resource files. - Forgetting to export a router, or mixing
requireandimportacross files, are the most common setup mistakes.
