Express.js Router Patterns for Large Applications
A practical guide to organizing Express.js routes for large applications covering feature-based routing, nested routers, versioned APIs, and middleware patterns.
Express.js Router Patterns for Large Applications
Overview
Every Express.js application starts with a handful of routes in a single file. Then it grows. Before long you have 80 endpoints, authentication middleware tangled into business logic, and route definitions scattered across files with no clear pattern. Express.js ships with express.Router() specifically to solve this problem — it gives you composable, mountable route handlers that can carry their own middleware, validation, and error handling. This article covers the patterns that keep large Express applications maintainable as they scale past 50, 100, or 200+ routes.
Prerequisites
- Working knowledge of Express.js (creating an app, defining routes, using middleware)
- Node.js v16 or later installed
- Familiarity with CommonJS module system (
require/module.exports) - Basic understanding of REST API design
express.Router() Basics and Why It Exists
At its core, express.Router() is a mini Express application. It can register routes, attach middleware, and handle errors — but it does not listen on a port. You mount it onto your main app at a specific path prefix.
var express = require("express");
var router = express.Router();
router.get("/", function(req, res) {
res.json({ message: "Users list" });
});
router.get("/:id", function(req, res) {
res.json({ message: "User " + req.params.id });
});
module.exports = router;
Then in your main app:
var express = require("express");
var app = express();
var usersRouter = require("./routes/users");
app.use("/users", usersRouter);
app.listen(3000, function() {
console.log("Server running on port 3000");
});
Every route defined on the router is now relative to /users. The router does not know or care about its mount point — that is the parent's concern. This decoupling is what makes routers composable.
Without routers, a large application devolves into a single file with hundreds of app.get() and app.post() calls, each fighting for position. Routers let you split your application into cohesive modules, each owning a slice of the URL namespace.
Route Grouping by Feature
The most important organizational decision you will make is how you group routes. There are two schools: by type (all controllers in one folder, all models in another) and by feature (everything related to users in one folder, everything related to products in another).
For applications with more than 20 routes, feature-based grouping wins every time. Here is why: when you need to change how orders work, you open one folder. You do not hunt across controllers/, routes/, validators/, and middleware/ to find the four files that together form the orders feature.
routes/
users/
index.js # Router definition and route registration
controller.js # Route handlers (business logic)
validation.js # Input validation middleware
middleware.js # Feature-specific middleware
products/
index.js
controller.js
validation.js
middleware.js
orders/
index.js
controller.js
validation.js
middleware.js
Each feature's index.js exports a router:
// routes/users/index.js
var express = require("express");
var router = express.Router();
var controller = require("./controller");
var validation = require("./validation");
router.get("/", controller.list);
router.get("/:id", validation.validateId, controller.getById);
router.post("/", validation.validateCreate, controller.create);
router.put("/:id", validation.validateId, validation.validateUpdate, controller.update);
router.delete("/:id", validation.validateId, controller.remove);
module.exports = router;
This pattern scales. When you add a new feature, you create a new folder with the same structure. No existing files change.
Nested Routers and Mount Points
Routers can mount other routers. This is how you model hierarchical resources like /users/:userId/addresses without polluting the users router with address logic.
// routes/users/addresses/index.js
var express = require("express");
var router = express.Router({ mergeParams: true });
var controller = require("./controller");
router.get("/", controller.listForUser);
router.post("/", controller.addToUser);
router.delete("/:addressId", controller.removeFromUser);
module.exports = router;
The mergeParams: true option is critical. Without it, the nested router cannot access :userId from the parent mount point.
// routes/users/index.js
var express = require("express");
var router = express.Router();
var addressesRouter = require("./addresses");
var controller = require("./controller");
router.get("/", controller.list);
router.get("/:userId", controller.getById);
router.use("/:userId/addresses", addressesRouter);
module.exports = router;
Now a request to GET /users/42/addresses flows through the users router, matches /:userId/addresses, and hands off to the addresses router. The addresses controller can read req.params.userId because of mergeParams.
You can nest as deep as you need, but in practice more than two levels of nesting usually signals that your URL design needs rethinking.
Route Parameter Validation with Middleware
Express provides router.param() to intercept and validate route parameters before any handler runs. This is cleaner than repeating validation in every handler.
// routes/users/index.js
var express = require("express");
var router = express.Router();
var User = require("../../models/user");
router.param("userId", function(req, res, next, id) {
if (!/^[0-9a-fA-F]{24}$/.test(id)) {
return res.status(400).json({ error: "Invalid user ID format" });
}
User.findById(id, function(err, user) {
if (err) return next(err);
if (!user) return res.status(404).json({ error: "User not found" });
req.user = user;
next();
});
});
router.get("/:userId", function(req, res) {
// req.user is already loaded and validated
res.json(req.user);
});
router.put("/:userId", function(req, res) {
// req.user is already loaded here too
req.user.name = req.body.name;
req.user.save(function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json(req.user);
});
});
Every route that includes :userId automatically gets the user loaded onto req.user. No duplication, no forgotten checks.
For request body validation, a middleware function works well:
// routes/users/validation.js
function validateCreate(req, res, next) {
var errors = [];
if (!req.body.email) {
errors.push("Email is required");
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(req.body.email)) {
errors.push("Email format is invalid");
}
if (!req.body.name || req.body.name.trim().length < 2) {
errors.push("Name must be at least 2 characters");
}
if (errors.length > 0) {
return res.status(400).json({ errors: errors });
}
next();
}
function validateId(req, res, next) {
if (!/^[0-9a-fA-F]{24}$/.test(req.params.id)) {
return res.status(400).json({ error: "Invalid ID format" });
}
next();
}
module.exports = {
validateCreate: validateCreate,
validateId: validateId
};
Versioned API Routes
API versioning is non-negotiable for production services. The cleanest approach with Express routers is prefix-based versioning where each version is a separate router tree.
// routes/api/v1/index.js
var express = require("express");
var router = express.Router();
var usersV1 = require("./users");
var productsV1 = require("./products");
router.use("/users", usersV1);
router.use("/products", productsV1);
module.exports = router;
// routes/api/v2/index.js
var express = require("express");
var router = express.Router();
var usersV2 = require("./users");
var productsV2 = require("./products");
router.use("/users", usersV2);
router.use("/products", productsV2);
module.exports = router;
// app.js
var v1Router = require("./routes/api/v1");
var v2Router = require("./routes/api/v2");
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);
The v2 routes can reuse v1 controllers where nothing changed and override only what is different:
// routes/api/v2/users/controller.js
var v1Controller = require("../../v1/users/controller");
// Reuse unchanged handlers
var list = v1Controller.list;
// Override the response format for getById
function getById(req, res, next) {
v1Controller.getByIdRaw(req, function(err, user) {
if (err) return next(err);
// v2 wraps responses in a data envelope
res.json({
data: user,
meta: { version: "v2", timestamp: Date.now() }
});
});
}
module.exports = {
list: list,
getById: getById
};
This approach avoids duplicating your entire codebase for each version. Only the parts that change get new files.
Route-Level Middleware Chains
One of the most powerful features of Express routers is attaching middleware at the router level rather than the application level. This means authentication, logging, rate limiting, and other concerns can vary by feature.
// routes/admin/index.js
var express = require("express");
var router = express.Router();
var auth = require("../../middleware/auth");
var rbac = require("../../middleware/rbac");
var auditLog = require("../../middleware/auditLog");
// Every route in this router requires authentication + admin role + audit logging
router.use(auth.requireToken);
router.use(rbac.requireRole("admin"));
router.use(auditLog.logAction);
router.get("/dashboard", function(req, res) {
res.json({ stats: "..." });
});
router.get("/users", function(req, res) {
res.json({ allUsers: "..." });
});
router.post("/settings", function(req, res) {
res.json({ updated: true });
});
module.exports = router;
Compare this to attaching auth.requireToken to every single admin route individually. The router-level approach is less error-prone — you cannot accidentally forget it on a new route.
You can also chain middleware on individual routes when only some routes need extra checks:
var rateLimit = require("../../middleware/rateLimit");
// Public route — no auth
router.get("/products", controller.list);
// Authenticated route
router.post("/products", auth.requireToken, controller.create);
// Authenticated + rate limited
router.post("/products/bulk", auth.requireToken, rateLimit.strict, controller.bulkCreate);
Error Handling Per Router
Express error handlers are middleware functions with four arguments: (err, req, res, next). You can define error handlers specific to a router so that errors from the users module are handled differently from errors in the payments module.
// routes/payments/index.js
var express = require("express");
var router = express.Router();
var controller = require("./controller");
router.post("/charge", controller.charge);
router.post("/refund", controller.refund);
// Payment-specific error handler
router.use(function(err, req, res, next) {
if (err.type === "StripeCardError") {
return res.status(402).json({
error: "Payment failed",
code: err.code,
message: err.message
});
}
if (err.type === "StripeInvalidRequestError") {
console.error("Stripe config error:", err.message);
return res.status(500).json({
error: "Payment service configuration error"
});
}
// Pass unhandled errors to the global handler
next(err);
});
module.exports = router;
The key detail: router-level error handlers only catch errors from routes within that router. Errors that are not handled get forwarded to the next error handler up the chain — typically the global one in app.js.
// app.js — global error handler (always define this last)
app.use(function(err, req, res, next) {
console.error("Unhandled error:", err.stack);
res.status(err.status || 500).json({
error: process.env.NODE_ENV === "production"
? "Internal server error"
: err.message
});
});
Lazy-Loaded Routes for Large Applications
In a large application with dozens of feature modules, loading every router at startup can slow down boot time and increase memory usage. Lazy loading defers the require() call until the first request hits that route.
// routes/index.js
var express = require("express");
var router = express.Router();
function lazyLoad(modulePath) {
var handler = null;
return function(req, res, next) {
if (!handler) {
handler = require(modulePath);
}
handler(req, res, next);
};
}
router.use("/users", lazyLoad("./users"));
router.use("/products", lazyLoad("./products"));
router.use("/analytics", lazyLoad("./analytics")); // Heavy module, rarely accessed
router.use("/reports", lazyLoad("./reports")); // Heavy module, rarely accessed
module.exports = router;
This pattern is especially useful for admin dashboards or reporting modules that most requests never touch. The analytics router and its dependencies are only loaded when someone first hits /analytics/*.
A more robust version caches the loaded module:
function lazyRouter(modulePath) {
var loaded = null;
return function(req, res, next) {
if (!loaded) {
console.log("Lazy loading router:", modulePath);
loaded = require(modulePath);
}
loaded(req, res, next);
};
}
Route Organization: Feature vs Type
Here is a side-by-side comparison. Consider an application with users, products, and orders.
By type (avoid for large apps):
controllers/
usersController.js
productsController.js
ordersController.js
routes/
usersRoutes.js
productsRoutes.js
ordersRoutes.js
middleware/
usersMiddleware.js
productsMiddleware.js
ordersMiddleware.js
validators/
usersValidator.js
productsValidator.js
ordersValidator.js
To change how users work, you touch four folders. To understand the users feature, you read four disconnected files. To delete the orders feature, you hunt through four folders removing files.
By feature (recommended):
routes/
users/
index.js
controller.js
validation.js
middleware.js
products/
index.js
controller.js
validation.js
middleware.js
orders/
index.js
controller.js
validation.js
middleware.js
Every file related to users is in routes/users/. Deleting orders means removing one folder. Adding a feature means adding one folder. This is the pattern that scales.
Dynamic Route Registration
For applications with many feature modules, manually requiring each router in app.js becomes tedious. You can auto-discover and register routes dynamically.
// routes/index.js
var express = require("express");
var router = express.Router();
var fs = require("fs");
var path = require("path");
var routesDir = __dirname;
fs.readdirSync(routesDir).forEach(function(entry) {
var entryPath = path.join(routesDir, entry);
var stat = fs.statSync(entryPath);
if (stat.isDirectory()) {
var routeModule = path.join(entryPath, "index.js");
if (fs.existsSync(routeModule)) {
var featureRouter = require(routeModule);
var mountPoint = "/" + entry;
router.use(mountPoint, featureRouter);
console.log("Registered route:", mountPoint);
}
}
});
module.exports = router;
Output:
Registered route: /admin
Registered route: /orders
Registered route: /products
Registered route: /users
Then in app.js:
var routes = require("./routes");
app.use(routes);
One line. No matter how many features you add, app.js does not change. The only convention is that each feature folder has an index.js that exports a router.
To control mount order or exclude certain folders, use a manifest file:
// routes/manifest.json
{
"routes": [
{ "path": "/users", "module": "./users" },
{ "path": "/products", "module": "./products" },
{ "path": "/orders", "module": "./orders" },
{ "path": "/admin", "module": "./admin" }
]
}
// routes/index.js
var express = require("express");
var router = express.Router();
var manifest = require("./manifest.json");
manifest.routes.forEach(function(route) {
router.use(route.path, require(route.module));
console.log("Registered:", route.path);
});
module.exports = router;
Router-Level Authentication and Authorization
Different parts of your application have different security requirements. Public routes, authenticated routes, and admin routes each need different middleware stacks.
// middleware/auth.js
var jwt = require("jsonwebtoken");
function requireToken(req, res, next) {
var header = req.headers.authorization;
if (!header || !header.startsWith("Bearer ")) {
return res.status(401).json({ error: "Authentication required" });
}
var token = header.slice(7);
try {
var decoded = jwt.verify(token, process.env.JWT_SECRET);
req.auth = decoded;
next();
} catch (err) {
return res.status(401).json({ error: "Invalid or expired token" });
}
}
function requireRole(role) {
return function(req, res, next) {
if (!req.auth || req.auth.role !== role) {
return res.status(403).json({ error: "Insufficient permissions" });
}
next();
};
}
module.exports = {
requireToken: requireToken,
requireRole: requireRole
};
Apply at the router level:
// Public routes — no auth middleware on the router
var publicRouter = express.Router();
publicRouter.get("/products", controller.list);
publicRouter.get("/products/:id", controller.getById);
// Authenticated routes — auth applied to entire router
var userRouter = express.Router();
userRouter.use(auth.requireToken);
userRouter.get("/profile", controller.getProfile);
userRouter.put("/profile", controller.updateProfile);
// Admin routes — auth + role check on entire router
var adminRouter = express.Router();
adminRouter.use(auth.requireToken);
adminRouter.use(auth.requireRole("admin"));
adminRouter.get("/users", controller.listAllUsers);
adminRouter.delete("/users/:id", controller.deleteUser);
Shared Middleware Between Routes
Some middleware needs to run across multiple routers but not all of them. Instead of duplicating it, create a middleware stack that can be composed.
// middleware/common.js
var cors = require("cors");
var helmet = require("helmet");
var apiMiddleware = [
cors({ origin: process.env.ALLOWED_ORIGINS.split(",") }),
helmet(),
express.json({ limit: "1mb" })
];
var webMiddleware = [
express.urlencoded({ extended: true }),
require("csurf")({ cookie: true })
];
module.exports = {
apiMiddleware: apiMiddleware,
webMiddleware: webMiddleware
};
// app.js
var common = require("./middleware/common");
// API routes get CORS + Helmet + JSON parsing
app.use("/api", common.apiMiddleware, require("./routes/api"));
// Web routes get CSRF protection + form parsing
app.use("/", common.webMiddleware, require("./routes/web"));
This prevents middleware from leaking across boundaries. Your API routes do not get CSRF protection (they should not need it — they use tokens). Your web routes do not get CORS headers.
Route Testing Strategies
Testing routes in isolation is straightforward with supertest. Because each router is a standalone module, you can mount it on a test app without starting the full application.
// routes/users/__tests__/users.test.js
var express = require("express");
var request = require("supertest");
var usersRouter = require("../index");
function createTestApp() {
var app = express();
app.use(express.json());
app.use("/users", usersRouter);
app.use(function(err, req, res, next) {
res.status(err.status || 500).json({ error: err.message });
});
return app;
}
describe("Users Router", function() {
var app;
beforeEach(function() {
app = createTestApp();
});
it("GET /users should return a list", function(done) {
request(app)
.get("/users")
.expect(200)
.expect("Content-Type", /json/)
.end(function(err, res) {
if (err) return done(err);
expect(Array.isArray(res.body)).toBe(true);
done();
});
});
it("GET /users/invalid-id should return 400", function(done) {
request(app)
.get("/users/not-a-valid-id")
.expect(400)
.end(function(err, res) {
if (err) return done(err);
expect(res.body.error).toMatch(/Invalid/);
done();
});
});
it("POST /users without email should return 400", function(done) {
request(app)
.post("/users")
.send({ name: "Test" })
.expect(400)
.end(function(err, res) {
if (err) return done(err);
expect(res.body.errors).toContain("Email is required");
done();
});
});
});
Run with:
npx jest routes/users/__tests__/users.test.js
For routes that require authentication, inject a mock auth middleware in your test app:
function createAuthenticatedApp(userPayload) {
var app = express();
app.use(express.json());
app.use(function(req, res, next) {
req.auth = userPayload || { id: "test-user", role: "user" };
next();
});
app.use("/users", usersRouter);
return app;
}
Route Documentation Generation
Large applications benefit from auto-generated route documentation. You can build a simple route listing utility.
// utils/routeList.js
function listRoutes(router, basePath) {
var routes = [];
router.stack.forEach(function(layer) {
if (layer.route) {
var methods = Object.keys(layer.route.methods)
.map(function(m) { return m.toUpperCase(); })
.join(", ");
routes.push({
method: methods,
path: basePath + layer.route.path,
middlewareCount: layer.route.stack.length
});
} else if (layer.name === "router" && layer.handle.stack) {
var prefix = basePath + (layer.keys.length
? layer.regexp.source.replace("\\/?(?=\\/|$)", "").replace(/\\\//g, "/").replace(/\^/, "")
: layer.regexp.source.replace("\\/?(?=\\/|$)", "").replace(/\\\//g, "/").replace(/\^/, ""));
routes = routes.concat(listRoutes(layer.handle, prefix));
}
});
return routes;
}
module.exports = { listRoutes: listRoutes };
// scripts/list-routes.js
var app = require("../app");
var routeList = require("../utils/routeList");
var routes = routeList.listRoutes(app._router, "");
console.log("\nRegistered Routes:");
console.log("-".repeat(60));
routes.forEach(function(r) {
console.log(r.method.padEnd(8) + r.path);
});
node scripts/list-routes.js
Registered Routes:
------------------------------------------------------------
GET /users
GET /users/:id
POST /users
PUT /users/:id
DELETE /users/:id
GET /products
GET /products/:id
POST /products
GET /orders
POST /orders
GET /orders/:id
GET /admin/dashboard
GET /admin/users
POST /admin/settings
Real-World File Structure for 50+ Routes
Here is the structure I use for production applications with 50 to 200 routes:
project/
app.js # Express app setup (middleware, error handler)
server.js # HTTP server startup
routes/
index.js # Auto-discovers and mounts feature routers
users/
index.js # Router: mounts sub-routes
controller.js # Handler functions
validation.js # Request validation middleware
middleware.js # User-specific middleware
addresses/
index.js # Nested router for /users/:id/addresses
controller.js
products/
index.js
controller.js
validation.js
categories/
index.js
controller.js
orders/
index.js
controller.js
validation.js
middleware.js
payments/
index.js
controller.js
admin/
index.js # Admin router (auth + role middleware)
dashboard/
index.js
controller.js
settings/
index.js
controller.js
auth/
index.js
controller.js
strategies/ # Passport strategies or similar
local.js
google.js
api/
v1/
index.js # v1 API router
users.js
products.js
v2/
index.js # v2 API router
users.js
products.js
webhooks/
index.js
stripe.js
github.js
middleware/
auth.js # Authentication
rbac.js # Role-based access control
rateLimit.js # Rate limiting
auditLog.js # Audit logging
errorHandler.js # Global error handler
requestId.js # Add unique request ID
models/
user.js
product.js
order.js
utils/
routeList.js
slugify.js
Complete Working Example
Here is a complete, runnable application demonstrating all of these patterns together. This represents a simplified e-commerce API with users, products, orders, and admin modules.
Project setup:
mkdir express-large-app && cd express-large-app
npm init -y
npm install express jsonwebtoken
app.js — Main application:
var express = require("express");
var app = express();
// Global middleware
app.use(express.json());
// Request ID middleware
app.use(function(req, res, next) {
req.id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
res.set("X-Request-Id", req.id);
next();
});
// Request logging
app.use(function(req, res, next) {
var start = Date.now();
res.on("finish", function() {
var duration = Date.now() - start;
console.log("[%s] %s %s %d %dms", req.id, req.method, req.url, res.statusCode, duration);
});
next();
});
// Health check (outside feature routers)
app.get("/health", function(req, res) {
res.json({ status: "ok", uptime: process.uptime() });
});
// Feature routers
var usersRouter = require("./routes/users");
var productsRouter = require("./routes/products");
var ordersRouter = require("./routes/orders");
var adminRouter = require("./routes/admin");
app.use("/users", usersRouter);
app.use("/products", productsRouter);
app.use("/orders", ordersRouter);
app.use("/admin", adminRouter);
// 404 handler
app.use(function(req, res) {
res.status(404).json({ error: "Route not found", path: req.originalUrl });
});
// Global error handler
app.use(function(err, req, res, next) {
console.error("[%s] Error:", req.id, err.stack);
var statusCode = err.status || 500;
res.status(statusCode).json({
error: statusCode === 500 ? "Internal server error" : err.message,
requestId: req.id
});
});
module.exports = app;
server.js — Entry point:
var app = require("./app");
var PORT = process.env.PORT || 3000;
app.listen(PORT, function() {
console.log("Server running on port %d", PORT);
});
middleware/auth.js — Authentication:
var jwt = require("jsonwebtoken");
var SECRET = process.env.JWT_SECRET || "dev-secret-do-not-use-in-production";
function requireToken(req, res, next) {
var header = req.headers.authorization;
if (!header || !header.startsWith("Bearer ")) {
var err = new Error("Authentication required");
err.status = 401;
return next(err);
}
try {
var token = header.slice(7);
req.auth = jwt.verify(token, SECRET);
next();
} catch (e) {
var err = new Error("Invalid or expired token");
err.status = 401;
next(err);
}
}
function requireRole(role) {
return function(req, res, next) {
if (!req.auth || req.auth.role !== role) {
var err = new Error("Forbidden: requires " + role + " role");
err.status = 403;
return next(err);
}
next();
};
}
function generateToken(payload) {
return jwt.sign(payload, SECRET, { expiresIn: "24h" });
}
module.exports = {
requireToken: requireToken,
requireRole: requireRole,
generateToken: generateToken
};
routes/users/index.js — Users router:
var express = require("express");
var router = express.Router();
var controller = require("./controller");
var validation = require("./validation");
var auth = require("../../middleware/auth");
// Public routes
router.post("/register", validation.validateRegistration, controller.register);
router.post("/login", controller.login);
// Authenticated routes
router.get("/me", auth.requireToken, controller.getProfile);
router.put("/me", auth.requireToken, validation.validateProfileUpdate, controller.updateProfile);
// Admin-accessible routes
router.get("/", auth.requireToken, auth.requireRole("admin"), controller.list);
router.get("/:id", auth.requireToken, controller.getById);
module.exports = router;
routes/users/controller.js — Users controller:
var auth = require("../../middleware/auth");
// In-memory store for demonstration
var users = [
{ id: "1", name: "Alice Admin", email: "[email protected]", role: "admin", password: "admin123" },
{ id: "2", name: "Bob User", email: "[email protected]", role: "user", password: "user123" }
];
function register(req, res) {
var newUser = {
id: String(users.length + 1),
name: req.body.name,
email: req.body.email,
role: "user",
password: req.body.password
};
users.push(newUser);
var token = auth.generateToken({ id: newUser.id, role: newUser.role });
res.status(201).json({
user: { id: newUser.id, name: newUser.name, email: newUser.email },
token: token
});
}
function login(req, res, next) {
var user = users.find(function(u) {
return u.email === req.body.email && u.password === req.body.password;
});
if (!user) {
var err = new Error("Invalid email or password");
err.status = 401;
return next(err);
}
var token = auth.generateToken({ id: user.id, role: user.role });
res.json({ token: token });
}
function getProfile(req, res) {
var user = users.find(function(u) { return u.id === req.auth.id; });
res.json({ id: user.id, name: user.name, email: user.email, role: user.role });
}
function updateProfile(req, res) {
var user = users.find(function(u) { return u.id === req.auth.id; });
if (req.body.name) user.name = req.body.name;
if (req.body.email) user.email = req.body.email;
res.json({ id: user.id, name: user.name, email: user.email });
}
function list(req, res) {
var safeList = users.map(function(u) {
return { id: u.id, name: u.name, email: u.email, role: u.role };
});
res.json(safeList);
}
function getById(req, res, next) {
var user = users.find(function(u) { return u.id === req.params.id; });
if (!user) {
var err = new Error("User not found");
err.status = 404;
return next(err);
}
res.json({ id: user.id, name: user.name, email: user.email, role: user.role });
}
module.exports = {
register: register,
login: login,
getProfile: getProfile,
updateProfile: updateProfile,
list: list,
getById: getById
};
routes/users/validation.js — Users validation:
function validateRegistration(req, res, next) {
var errors = [];
if (!req.body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(req.body.email)) {
errors.push("Valid email is required");
}
if (!req.body.name || req.body.name.trim().length < 2) {
errors.push("Name must be at least 2 characters");
}
if (!req.body.password || req.body.password.length < 8) {
errors.push("Password must be at least 8 characters");
}
if (errors.length > 0) {
return res.status(400).json({ errors: errors });
}
next();
}
function validateProfileUpdate(req, res, next) {
if (req.body.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(req.body.email)) {
return res.status(400).json({ errors: ["Invalid email format"] });
}
next();
}
module.exports = {
validateRegistration: validateRegistration,
validateProfileUpdate: validateProfileUpdate
};
routes/products/index.js — Products router:
var express = require("express");
var router = express.Router();
var controller = require("./controller");
var auth = require("../../middleware/auth");
// Public routes
router.get("/", controller.list);
router.get("/:id", controller.getById);
// Protected routes
router.post("/", auth.requireToken, auth.requireRole("admin"), controller.create);
router.put("/:id", auth.requireToken, auth.requireRole("admin"), controller.update);
module.exports = router;
routes/products/controller.js — Products controller:
var products = [
{ id: "1", name: "Widget Pro", price: 29.99, category: "tools", stock: 150 },
{ id: "2", name: "Gadget Plus", price: 49.99, category: "electronics", stock: 75 }
];
function list(req, res) {
var result = products;
if (req.query.category) {
result = products.filter(function(p) { return p.category === req.query.category; });
}
res.json(result);
}
function getById(req, res, next) {
var product = products.find(function(p) { return p.id === req.params.id; });
if (!product) {
var err = new Error("Product not found");
err.status = 404;
return next(err);
}
res.json(product);
}
function create(req, res) {
var product = {
id: String(products.length + 1),
name: req.body.name,
price: req.body.price,
category: req.body.category,
stock: req.body.stock || 0
};
products.push(product);
res.status(201).json(product);
}
function update(req, res, next) {
var product = products.find(function(p) { return p.id === req.params.id; });
if (!product) {
var err = new Error("Product not found");
err.status = 404;
return next(err);
}
if (req.body.name) product.name = req.body.name;
if (req.body.price) product.price = req.body.price;
if (req.body.stock !== undefined) product.stock = req.body.stock;
res.json(product);
}
module.exports = {
list: list,
getById: getById,
create: create,
update: update
};
routes/orders/index.js — Orders router with feature-specific error handling:
var express = require("express");
var router = express.Router();
var controller = require("./controller");
var auth = require("../../middleware/auth");
// All order routes require authentication
router.use(auth.requireToken);
router.get("/", controller.listMine);
router.post("/", controller.create);
router.get("/:id", controller.getById);
router.post("/:id/cancel", controller.cancel);
// Order-specific error handler
router.use(function(err, req, res, next) {
if (err.code === "INSUFFICIENT_STOCK") {
return res.status(409).json({
error: "Insufficient stock",
product: err.productId,
available: err.available,
requested: err.requested
});
}
next(err);
});
module.exports = router;
routes/orders/controller.js — Orders controller:
var orders = [];
function listMine(req, res) {
var userOrders = orders.filter(function(o) { return o.userId === req.auth.id; });
res.json(userOrders);
}
function create(req, res, next) {
if (!req.body.productId || !req.body.quantity) {
var err = new Error("productId and quantity are required");
err.status = 400;
return next(err);
}
var order = {
id: String(orders.length + 1),
userId: req.auth.id,
productId: req.body.productId,
quantity: req.body.quantity,
status: "pending",
createdAt: new Date().toISOString()
};
orders.push(order);
res.status(201).json(order);
}
function getById(req, res, next) {
var order = orders.find(function(o) {
return o.id === req.params.id && o.userId === req.auth.id;
});
if (!order) {
var err = new Error("Order not found");
err.status = 404;
return next(err);
}
res.json(order);
}
function cancel(req, res, next) {
var order = orders.find(function(o) {
return o.id === req.params.id && o.userId === req.auth.id;
});
if (!order) {
var err = new Error("Order not found");
err.status = 404;
return next(err);
}
if (order.status !== "pending") {
var err = new Error("Only pending orders can be cancelled");
err.status = 400;
return next(err);
}
order.status = "cancelled";
res.json(order);
}
module.exports = {
listMine: listMine,
create: create,
getById: getById,
cancel: cancel
};
routes/admin/index.js — Admin router with layered security:
var express = require("express");
var router = express.Router();
var auth = require("../../middleware/auth");
// All admin routes require authentication and admin role
router.use(auth.requireToken);
router.use(auth.requireRole("admin"));
// Audit logging for admin actions
router.use(function(req, res, next) {
console.log("[ADMIN AUDIT] %s %s by user %s at %s",
req.method, req.originalUrl, req.auth.id, new Date().toISOString());
next();
});
router.get("/dashboard", function(req, res) {
res.json({
totalUsers: 2,
totalProducts: 2,
totalOrders: 0,
serverUptime: process.uptime()
});
});
router.get("/system", function(req, res) {
res.json({
nodeVersion: process.version,
memoryUsage: process.memoryUsage(),
platform: process.platform
});
});
module.exports = router;
Test the application:
node server.js
# Register a new user
curl -X POST http://localhost:3000/users/register \
-H "Content-Type: application/json" \
-d '{"name":"Charlie","email":"[email protected]","password":"secure123"}'
# Login
curl -X POST http://localhost:3000/users/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"admin123"}'
# Browse products (no auth needed)
curl http://localhost:3000/products
# Create an order (auth required — use the token from login)
curl -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"productId":"1","quantity":2}'
Common Issues and Troubleshooting
1. Route Order Matters — Catch-All Swallowing Routes
Error: Cannot GET /users/profile
(returns 404 or wrong handler)
This happens when a parameterized route is defined before a specific route:
// WRONG — /:id matches "profile" as an id
router.get("/:id", controller.getById);
router.get("/profile", controller.getProfile);
// CORRECT — specific routes before parameterized routes
router.get("/profile", controller.getProfile);
router.get("/:id", controller.getById);
Express evaluates routes in the order they are defined. The first match wins.
2. Missing mergeParams on Nested Routers
TypeError: Cannot read properties of undefined (reading 'userId')
When a nested router tries to access a parent parameter:
// WRONG
var addressRouter = express.Router();
// CORRECT
var addressRouter = express.Router({ mergeParams: true });
Without mergeParams: true, req.params in the child router does not include parameters from the parent's mount point.
3. Error Handler Not Catching Errors
UnhandledPromiseRejectionWarning: Error: Something broke
If your route handler is asynchronous but does not pass errors to next(), the error handler never fires:
// WRONG — error is thrown but not caught
router.get("/data", function(req, res) {
fetchData(function(err, data) {
if (err) throw err; // This crashes the process
res.json(data);
});
});
// CORRECT — pass error to next()
router.get("/data", function(req, res, next) {
fetchData(function(err, data) {
if (err) return next(err); // Error handler catches this
res.json(data);
});
});
4. Middleware Applied After Routes Has No Effect
// WRONG — auth middleware runs AFTER routes are defined, so routes are unprotected
router.get("/secret", controller.getSecret);
router.use(auth.requireToken);
// CORRECT — middleware before routes
router.use(auth.requireToken);
router.get("/secret", controller.getSecret);
Middleware runs in the order it is registered. If you attach router.use() after router.get(), the GET handler runs first without the middleware.
5. Router Mounted at Wrong Path
GET /api/api/users 404 Not Found
This happens when you accidentally double the prefix:
// routes/api/index.js
var router = express.Router();
router.use("/api/users", usersRouter); // WRONG — prefix is already /api from the mount
// app.js
app.use("/api", require("./routes/api"));
// Fix: remove the duplicate prefix
router.use("/users", usersRouter); // CORRECT — /api comes from the mount point
6. res.json() Called After res.send()
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
This typically occurs when you forget to return after sending a response in validation middleware:
// WRONG — missing return, execution continues
function validate(req, res, next) {
if (!req.body.name) {
res.status(400).json({ error: "Name required" });
}
next(); // This runs even after sending the 400
}
// CORRECT
function validate(req, res, next) {
if (!req.body.name) {
return res.status(400).json({ error: "Name required" });
}
next();
}
Best Practices
Group routes by feature, not by type. A
routes/users/folder with controller, validation, and middleware is easier to navigate than four separate top-level folders. Every file you need is in one place.Define specific routes before parameterized routes. Express matches routes in registration order.
/users/profilemust come before/users/:id, or "profile" will be treated as an ID.Always use
mergeParams: trueon nested routers. If your nested router might ever need access to parent route parameters, enable this option. There is no downside.Apply authentication at the router level, not the route level. When every route in a module needs auth, use
router.use(auth.requireToken)once instead of repeating it on every route definition. This eliminates an entire class of security bugs.Return early after sending responses. Every
res.json(),res.send(), orres.redirect()in middleware must be preceded byreturn. Otherwise execution continues and you get the "headers already sent" error.Use router-specific error handlers for domain-specific errors. A payment router should handle Stripe errors differently from how the users router handles validation errors. Let each domain translate its errors into appropriate HTTP responses.
Keep controllers thin. Route handlers should parse the request, call a service, and format the response. Business logic belongs in a service layer, not in controller functions. This makes your routes testable without mocking HTTP objects.
Use a consistent file naming convention. When every feature has
index.js,controller.js,validation.js, andmiddleware.js, developers can navigate the codebase without reading documentation.Version your APIs from day one. Adding versioning later is painful. Start with
/api/v1/even if you think you only need one version. It costs nothing upfront and saves significant refactoring later.Test routers in isolation with supertest. Mount just the router you are testing on a fresh Express app. This gives you fast, focused tests that do not depend on the full application bootstrap.
References
- Express.js Routing Guide — Official Express documentation on routing
- express.Router() API Reference — Full API documentation for Router
- Express.js Error Handling — Official guide to error handling middleware
- supertest on npm — HTTP assertions for testing Express routes
- jsonwebtoken on npm — JWT implementation used in the authentication examples