Routing
Routing is how Express maps incoming requests to handler functions. A well-structured router is the skeleton of your API — it should read like a table of contents for everything your API can do.
Anatomy of a URL
https://
api.example.com
:3000
/api/v1/users/42
?include=posts&page=2
| Part | Express access | Notes |
|---|---|---|
/api/v1/users/42 | req.path | Path only, no query string |
42 | req.params.id | Captured from :id — always a string |
include=posts | req.query.include | Query param — always a string, validate before use |
page=2 | req.query.page | Cast to number: +req.query.page |
Route Parameters — All Patterns
GET /users/:id
Single named segment.
req.params.id = '42'. Always a string.GET /orgs/:orgId/repos/:repoId
Multiple params.
req.params = { orgId: 'google', repoId: 'tensorflow' }.GET /files/:path(*)
Wildcard — matches any path after
/files/. req.params.path = 'docs/readme.md'.GET /users/:id(\\d+)
Regex constraint — only matches if id is digits. Returns 404 for
/users/abc.GET /users/:id?
Optional segment — matches both
/users and /users/42. req.params.id may be undefined.// req.params is always strings — cast before use
router.get('/:id', (req, res) => {
const id = +req.params.id; // cast to number
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'Invalid ID' });
}
// now safe to use id as a number
});
Query Strings — Patterns & Best Practices
// GET /users?page=2&limit=20&sort=name&order=desc&role=admin&active=true
router.get('/', (req, res) => {
// Pagination — clamp values to prevent abuse
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(100, parseInt(req.query.limit) || 20);
const offset = (page - 1) * limit;
// Sorting — whitelist allowed values to prevent injection
const ALLOWED_SORT = ['name', 'createdAt', 'email'];
const sort = ALLOWED_SORT.includes(req.query.sort) ? req.query.sort : 'createdAt';
const order = req.query.order === 'asc' ? 'asc' : 'desc';
// Filtering — only pass through known filters
const filters = {};
if (req.query.role) filters.role = req.query.role;
if (req.query.active) filters.active = req.query.active === 'true';
// Use for DB query
res.json({ page, limit, offset, sort, order, filters });
});
SQL Injection via Sort Column
Never pass req.query.sort directly into a SQL ORDER BY clause. An attacker sends ?sort=1;DROP TABLE users--. Always whitelist allowed sort fields.
Router — Organising into Files
// src/routes/users.js — routes only, no business logic
const { Router } = require('express');
const ctrl = require('../controllers/users');
const auth = require('../middleware/auth');
const validate = require('../middleware/validate');
const schemas = require('../schemas/users');
const router = Router();
// Public routes
router.post('/register', validate(schemas.register), ctrl.register);
router.post('/login', validate(schemas.login), ctrl.login);
// Protected routes — authenticate applies to everything below
router.use(auth.authenticate);
router.get('/', ctrl.getAll);
router.get('/:id', validate(schemas.getById), ctrl.getById);
router.patch('/:id', validate(schemas.update), ctrl.update);
router.delete('/:id',
auth.requireRole('admin'), // only admins can delete
ctrl.remove
);
module.exports = router;
// src/routes/index.js — single mounting point
const { Router } = require('express');
const router = Router();
router.use('/users', require('./users'));
router.use('/posts', require('./posts'));
router.use('/auth', require('./auth'));
module.exports = router;
// In app.js:
// app.use('/api/v1', require('./routes'));
// Result: /api/v1/users, /api/v1/posts, /api/v1/auth
Controllers — The Right Level of Abstraction
Routes should be thin — just middleware + handler name. Controllers should also be thin — just request/response handling. Business logic lives in services:
// src/controllers/users.js
const userService = require('../services/users');
const { NotFoundError } = require('../errors/AppError');
exports.getById = async (req, res) => {
const user = await userService.getById(+req.params.id);
if (!user) throw new NotFoundError('User');
res.json({ data: user });
};
exports.create = async (req, res) => {
const user = await userService.create(req.body);
res.status(201)
.set('Location', `/api/v1/users/${user.id}`)
.json({ data: user });
};
// src/services/users.js — pure business logic, no req/res, fully testable
const db = require('../db/client');
const bcrypt = require('bcrypt');
exports.create = async ({ name, email, password }) => {
const hashed = await bcrypt.hash(password, 12);
const user = await db.user.create({
data: { name, email, password: hashed },
select: { id: true, name: true, email: true }, // never return password
});
return user;
};
Nested Resources
// /api/v1/users/:userId/posts — posts belonging to a user
const postsRouter = require('./posts');
// Option A: mergeParams — child router can access parent params
const nestedPosts = Router({ mergeParams: true });
nestedPosts.get('/', async (req, res) => {
const { userId } = req.params; // available because of mergeParams
const posts = await db.post.findMany({ where: { userId: +userId } });
res.json({ data: posts });
});
userRouter.use('/:userId/posts', nestedPosts);
Nesting Depth Rule
Never nest routes more than 2 levels deep. /users/:id/posts is fine. /users/:id/posts/:postId/comments/:commentId/likes is a maintenance nightmare. At that depth, flatten it: /comments/:id/likes works just as well and is far more readable.
Router-level param() — DRY Parameter Handling
// Run code whenever :userId appears in this router's routes
// Perfect for: load the entity, 404 if missing, attach to req
router.param('userId', async (req, res, next, id) => {
const user = await db.user.findUnique({ where: { id: +id } });
if (!user) throw new NotFoundError('User');
req.targetUser = user; // now available in all routes with :userId
next();
});
// All these routes automatically get req.targetUser loaded
router.get('/:userId', (req, res) => res.json(req.targetUser));
router.patch('/:userId', (req, res) => { /* update req.targetUser */ });
router.delete('/:userId', (req, res) => { /* delete req.targetUser */ });
router.get('/:userId/posts', (req, res) => { /* req.targetUser.id available */ });
API Versioning Strategies
// Strategy 1 — URL path versioning (most common, easiest to reason about)
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Strategy 2 — Accept header versioning (more RESTful, harder to test in browser)
app.use('/api/users', (req, res, next) => {
const version = req.get('Accept')?.match(/version=(\d+)/)?.[1] ?? '1';
req.apiVersion = +version;
next();
});
// Strategy 3 — Query param (avoid — query params are for filtering, not versioning)
// GET /users?version=2 ← don't do this
The 404 Handler and Catch-All
// In app.js — after ALL route registrations
// Catches any request that didn't match a defined route
app.use((req, res) => {
res.status(404).json({
error: {
code: 'NOT_FOUND',
message: `Cannot ${req.method} ${req.path}`,
}
});
});
// Why not throw new NotFoundError() here?
// You can, but this middleware is synchronous and simple — no need to go through
// the error handler for something this routine. Direct res.json() is fine.
🧠 Check Your Understanding
Go Deeper
Primary source: Express.js — Routing
Deep dive: Express Router API — every method documented.
Ask your teacher: "Show me how mergeParams works with a complete nested resource example." or "What's the difference between router.use() and router.all()?"