Express Router: Structuring Large Applications

Express Router: Structuring Large Applications
A single app.js file with every route, every middleware, and every database call is fine for a tutorial. It is a disaster for a real product. When one file reaches 300 lines, reading it feels like archaeology. When a team of three people edits it simultaneously, git conflicts become a part-time job.
Express Router exists to fix this. It lets you split your application into self-contained route modules, each responsible for one slice of your API. Combined with a deliberate folder structure, it turns a tangled monolith into something you can actually navigate.
This is Module 13 of the Node.js Full‑Stack Developer course.
The Problem with One Big File
A minimal Express app that has grown beyond its means looks like this:
// app.js — the warning signs
app.get('/users', ...)
app.post('/users', ...)
app.get('/users/:id', ...)
app.put('/users/:id', ...)
app.delete('/users/:id', ...)
app.get('/products', ...)
app.post('/products', ...)
// ... 200 more linesEvery concern — routing, validation, business logic, database queries — lives in the same file. There is no way to read just the user-related code, no way to test a feature in isolation, and no way to delete a feature without reading every line to find what belongs to it.
express.Router() — The Foundation
express.Router() creates a mini-application that has its own middleware stack and route handlers, but no HTTP server. You build it like a small Express app, then mount it into the real app.
// routes/users.js
import express from 'express';
const router = express.Router();
router.get('/', (req, res) => {
res.json({ users: [] });
});
router.post('/', (req, res) => {
res.status(201).json({ created: true });
});
router.get('/:id', (req, res) => {
res.json({ id: req.params.id });
});
export default router;// app.js
import express from 'express';
import usersRouter from './routes/users.js';
const app = express();
app.use(express.json());
app.use('/api/users', usersRouter); // mount at /api/users
app.listen(3000);Every route defined on usersRouter is now prefixed with /api/users. router.get('/') becomes GET /api/users. router.get('/:id') becomes GET /api/users/:id. The router has no idea what prefix it will be mounted on — that decision belongs to app.js.
Router Middleware
Middleware attached to a router applies only to that router's routes — not the whole application.
// routes/admin.js
import express from 'express';
import { requireAuth } from '../middleware/auth.js';
import { requireRole } from '../middleware/rbac.js';
const router = express.Router();
// Every route in this router requires authentication
router.use(requireAuth);
// Only admin routes need the admin role check
router.use(requireRole('admin'));
router.get('/users', adminController.listUsers);
router.delete('/users/:id', adminController.deleteUser);
export default router;// app.js — public routes come first, no auth needed
app.use('/api/auth', authRouter);
app.use('/api/products', productsRouter);
// Admin routes are protected at the router level
app.use('/api/admin', adminRouter);This is cleaner than adding requireAuth to every individual route definition.
Project Structure: Layer-Based vs Feature-Based
Layer-Based (avoid for non-trivial apps)
src/
routes/
users.js
products.js
orders.js
controllers/
users.js
products.js
orders.js
services/
users.js
products.js
orders.js
models/
User.js
Product.js
Order.jsAdding the "orders" feature means touching four directories. Deleting it means hunting through four directories. Every feature change creates widespread file-system churn.
Feature-Based (recommended)
src/
features/
users/
users.router.js
users.controller.js
users.service.js
users.schema.js ← Joi/Zod validation schemas
users.test.js
products/
products.router.js
products.controller.js
products.service.js
products.schema.js
products.test.js
orders/
orders.router.js
orders.controller.js
orders.service.js
orders.schema.js
orders.test.js
middleware/
auth.js
errorHandler.js
requestId.js
validate.js
config/
db.js
env.js
app.js
server.jsEverything related to users lives in features/users/. Adding a feature means adding one folder. Deleting a feature means deleting one folder. No cross-cutting scars.
Implementing the Pattern — Users Feature
1. Schema (Validation)
// features/users/users.schema.js
import Joi from 'joi';
export const createUserSchema = Joi.object({
name: Joi.string().min(2).max(100).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
role: Joi.string().valid('user', 'admin').default('user'),
});
export const updateUserSchema = Joi.object({
name: Joi.string().min(2).max(100),
email: Joi.string().email(),
}).min(1); // at least one field required2. Service (Business Logic)
// features/users/users.service.js
import { User } from './users.model.js';
import { hashPassword } from '../../lib/crypto.js';
import { AppError } from '../../lib/errors.js';
export async function createUser(data) {
const exists = await User.findOne({ email: data.email });
if (exists) throw new AppError('Email already registered', 409);
const hashed = await hashPassword(data.password);
const user = await User.create({ ...data, password: hashed });
return sanitize(user);
}
export async function getUserById(id) {
const user = await User.findById(id);
if (!user) throw new AppError('User not found', 404);
return sanitize(user);
}
export async function listUsers({ page = 1, limit = 20 } = {}) {
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
User.find().skip(skip).limit(limit).lean(),
User.countDocuments(),
]);
return { users: users.map(sanitize), total, page, limit };
}
export async function updateUser(id, data) {
const user = await User.findByIdAndUpdate(id, data, { new: true, runValidators: true });
if (!user) throw new AppError('User not found', 404);
return sanitize(user);
}
export async function deleteUser(id) {
const user = await User.findByIdAndDelete(id);
if (!user) throw new AppError('User not found', 404);
}
// Never return the password field
function sanitize(user) {
const { password, __v, ...safe } = user.toObject ? user.toObject() : user;
return safe;
}3. Controller (HTTP Layer)
// features/users/users.controller.js
import * as usersService from './users.service.js';
export async function list(req, res, next) {
try {
const { page, limit } = req.query;
const result = await usersService.listUsers({ page: +page, limit: +limit });
res.json(result);
} catch (err) {
next(err);
}
}
export async function getById(req, res, next) {
try {
const user = await usersService.getUserById(req.params.id);
res.json(user);
} catch (err) {
next(err);
}
}
export async function create(req, res, next) {
try {
const user = await usersService.createUser(req.body);
res.status(201).json(user);
} catch (err) {
next(err);
}
}
export async function update(req, res, next) {
try {
const user = await usersService.updateUser(req.params.id, req.body);
res.json(user);
} catch (err) {
next(err);
}
}
export async function remove(req, res, next) {
try {
await usersService.deleteUser(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}Notice the controller's only job: parse req, call a service, write res. No business logic. No database queries. This makes controllers trivially readable and services independently testable.
4. Router
// features/users/users.router.js
import express from 'express';
import * as controller from './users.controller.js';
import { validate } from '../../middleware/validate.js';
import { requireAuth } from '../../middleware/auth.js';
import { requireRole } from '../../middleware/rbac.js';
import { createUserSchema, updateUserSchema } from './users.schema.js';
const router = express.Router();
// GET /api/users
router.get('/', requireAuth, controller.list);
// GET /api/users/:id
router.get('/:id', requireAuth, controller.getById);
// POST /api/users
router.post('/', requireAuth, requireRole('admin'), validate(createUserSchema), controller.create);
// PUT /api/users/:id
router.put('/:id', requireAuth, requireRole('admin'), validate(updateUserSchema), controller.update);
// DELETE /api/users/:id
router.delete('/:id', requireAuth, requireRole('admin'), controller.remove);
export default router;Each route lists exactly what it needs: auth check, role check, validation, then the controller. Reading it tells you the complete story of each endpoint.
Wiring Up app.js
// app.js
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { requestId } from './middleware/requestId.js';
import { requestLogger } from './middleware/logger.js';
import { errorHandler } from './middleware/errorHandler.js';
import { notFound } from './middleware/notFound.js';
// Feature routers
import authRouter from './features/auth/auth.router.js';
import usersRouter from './features/users/users.router.js';
import productsRouter from './features/products/products.router.js';
import ordersRouter from './features/orders/orders.router.js';
const app = express();
// ── Security & parsing ──────────────────────────────────────────────────────
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGIN }));
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// ── Observability ───────────────────────────────────────────────────────────
app.use(requestId);
app.use(requestLogger);
// ── Health check (no auth) ──────────────────────────────────────────────────
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// ── Feature routes ──────────────────────────────────────────────────────────
app.use('/api/v1/auth', authRouter);
app.use('/api/v1/users', usersRouter);
app.use('/api/v1/products', productsRouter);
app.use('/api/v1/orders', ordersRouter);
// ── 404 & error handling ────────────────────────────────────────────────────
app.use(notFound);
app.use(errorHandler);
export default app;// server.js — separated from app.js
import app from './app.js';
import { connectDB } from './config/db.js';
const PORT = process.env.PORT || 3000;
async function start() {
await connectDB();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
}
start().catch(console.error);Separating app.js from server.js is a small but important decision. Tests can import app from './app.js' and pass it to Supertest without starting the HTTP server. The server only starts when server.js runs.
The notFound and errorHandler Middleware
// middleware/notFound.js
export function notFound(req, res, next) {
const error = new Error(`Not found — ${req.method} ${req.originalUrl}`);
error.status = 404;
next(error);
}// middleware/errorHandler.js
export function errorHandler(err, req, res, next) {
const status = err.status || err.statusCode || 500;
const message = err.message || 'Internal Server Error';
// Don't leak stack traces in production
const body = {
status: 'error',
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
};
if (status >= 500) {
console.error({ requestId: req.id, err });
}
res.status(status).json(body);
}// lib/errors.js — a base error class with HTTP status
export class AppError extends Error {
constructor(message, statusCode = 500) {
super(message);
this.statusCode = statusCode;
this.status = statusCode;
Error.captureStackTrace(this, this.constructor);
}
}With AppError, services can throw new AppError('Not found', 404) and the global handler converts it to the right HTTP response. No try/catch ladder in routes, no scattered res.status(404).json(...) calls.
Nested Routers
For deeply nested resources (e.g., /api/orders/:orderId/items), you can nest routers:
// features/orders/items.router.js
import express from 'express';
const router = express.Router({ mergeParams: true }); // ← important!
router.get('/', async (req, res, next) => {
try {
// req.params.orderId is available because of mergeParams: true
const items = await itemsService.listByOrder(req.params.orderId);
res.json(items);
} catch (err) {
next(err);
}
});
export default router;// features/orders/orders.router.js
import express from 'express';
import itemsRouter from './items.router.js';
const router = express.Router();
router.use('/:orderId/items', itemsRouter);
router.get('/', controller.list);
router.get('/:orderId', controller.getById);
export default router;The mergeParams: true option is critical — without it, req.params.orderId is undefined inside the nested router because Express resets params at each router boundary.
Route Grouping with a Router Index
For features with many sub-routers, create an index that wires them together:
// features/index.js
import express from 'express';
import authRouter from './auth/auth.router.js';
import usersRouter from './users/users.router.js';
import productsRouter from './products/products.router.js';
import ordersRouter from './orders/orders.router.js';
const router = express.Router();
router.use('/auth', authRouter);
router.use('/users', usersRouter);
router.use('/products', productsRouter);
router.use('/orders', ordersRouter);
export default router;// app.js — one import instead of four
import featuresRouter from './features/index.js';
app.use('/api/v1', featuresRouter);Now app.js doesn't need to know about individual features. Adding a new feature means creating a folder and registering it in features/index.js.
Validate Middleware (Reusable)
// middleware/validate.js
export function validate(schema, property = 'body') {
return (req, res, next) => {
const { error, value } = schema.validate(req[property], {
abortEarly: false,
stripUnknown: true,
});
if (error) {
const details = error.details.map(d => ({
field: d.path.join('.'),
message: d.message,
}));
return res.status(422).json({ status: 'error', errors: details });
}
req[property] = value; // replace with sanitised value
next();
};
}Usage in any router:
router.post('/', validate(createUserSchema), controller.create);
router.get('/', validate(listQuerySchema, 'query'), controller.list);
router.put('/:id', validate(updateUserSchema), controller.update);One middleware, any schema, any request property.
Complete File Tree
The final structure of a production Express application using this pattern:
src/
├── app.js ← Express app (no server.listen)
├── server.js ← Starts HTTP server
├── config/
│ ├── db.js ← DB connection
│ └── env.js ← Validated env vars
├── features/
│ ├── index.js ← Mounts all feature routers
│ ├── auth/
│ │ ├── auth.router.js
│ │ ├── auth.controller.js
│ │ ├── auth.service.js
│ │ └── auth.schema.js
│ ├── users/
│ │ ├── users.router.js
│ │ ├── users.controller.js
│ │ ├── users.service.js
│ │ ├── users.model.js
│ │ ├── users.schema.js
│ │ └── users.test.js
│ └── products/
│ ├── products.router.js
│ ├── products.controller.js
│ ├── products.service.js
│ ├── products.model.js
│ ├── products.schema.js
│ └── products.test.js
├── middleware/
│ ├── auth.js
│ ├── errorHandler.js
│ ├── notFound.js
│ ├── rbac.js
│ ├── requestId.js
│ ├── requestLogger.js
│ └── validate.js
└── lib/
├── crypto.js ← hashPassword, comparePassword
└── errors.js ← AppError classNode.js Full‑Stack Course — Module 13 of 32
You can now structure any Express application for long-term maintainability. Continue to Module 14 to connect your API to MongoDB using Mongoose.
Summary
Express Router is the key to a maintainable Node.js API:
express.Router()creates a mountable mini-application — wire it intoapp.use('/prefix', router)- Feature-based folder structure keeps all related code together and makes features independently deletable
- The three-layer pattern — router → controller → service — separates HTTP concerns from business logic
mergeParams: truepasses parent route params to nested routers- A global
errorHandlermiddleware (4 params) catches everything forwarded vianext(err) - Separate
app.jsfromserver.jsso tests can import the app without starting an HTTP server
Continue to Module 14: MongoDB & Mongoose — CRUD Operations →
