🗺️ Routing and Middleware
Routing decides where a request goes; middleware decides what happens on the way. Together they are the beating heart of every Express app. This lesson takes you from a single app.get() to modular routers, chained handlers, and a production-grade error pipeline.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Define routes for every HTTP method and read route parameters and query strings
- Chain multiple handler functions to keep each one focused
- Split an app into modular, mountable routers with
express.Router() - Write custom middleware, including a configurable middleware factory
- Build a centralized error-handling pipeline with custom error classes
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Build a modular users API with validation middleware and a global error handler.
In This Lesson
Routing Basics
Routing is how an app's endpoints (URIs) respond to client requests. Each route is defined by a method call in the shape app.METHOD(PATH, HANDLER), where METHOD is a lowercase HTTP verb, PATH is a URL pattern, and HANDLER is the function that runs on a match.
📮 The mail-sorting analogy. Routing is a post office. The HTTP method is the service class (standard, express, registered); the path is the destination department; a route parameter is a specific mailbox within it; and the handler is the clerk who processes that kind of mail for that department.
const express = require('express');
const app = express();
app.use(express.json());
app.get('/', (req, res) => res.send('Hello World!'));
app.post('/users', (req, res) => {
res.status(201).json({ created: req.body });
});
app.put('/users/:id', (req, res) => {
res.send(`Updated user ${req.params.id}`);
});
app.delete('/users/:id', (req, res) => {
res.status(204).end();
});
app.listen(3000, () => console.log('Listening on 3000'));
Notice the status codes: 201 Created after a successful POST, and 204 No Content after a DELETE with nothing to return. Choosing accurate status codes is part of good routing, not an afterthought.
Route & Query Parameters
Express gives you two ways to receive data through the URL, and it's important to know which is which.
📖 Route parameters vs query parameters
Route parameters (/users/:id) are part of the path itself and identify which resource. They live on req.params.
Query parameters (/search?q=express&page=2) come after the ? and carry optional data like filters, sorting, and pagination. They live on req.query.
Route parameters — req.params
// One parameter
app.get('/users/:userId', (req, res) => {
res.send(`User ID: ${req.params.userId}`);
});
// Nested resources with multiple parameters
app.get('/users/:userId/posts/:postId', (req, res) => {
const { userId, postId } = req.params;
res.json({ userId, postId });
});
A request to /users/123/posts/456 sets userId to "123" and postId to "456". The nested path mirrors the real relationship: posts belong to a user.
Query parameters — req.query
app.get('/api/products', (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const category = req.query.category;
const minPrice = req.query.minPrice ? parseFloat(req.query.minPrice) : undefined;
const filter = {};
if (category) filter.category = category;
if (minPrice !== undefined) filter.price = { $gte: minPrice };
res.json({
filter,
pagination: { currentPage: page, itemsPerPage: limit }
});
});
⚠️ Query values are always strings
req.query.page is the string "2", not the number 2. Convert with parseInt() / parseFloat() and always supply a sensible default, because query parameters are optional and may be missing.
Combined, a URL like /api/products?category=electronics&minPrice=100&page=2&limit=10 gives you filtering and pagination in one clean, bookmarkable endpoint.
Chained Route Handlers
A route can take more than one handler function. Each behaves like route-specific middleware: it runs in order, and any one of them can end the cycle or call next() to advance. This lets you break a request into small, single-purpose steps.
// Step 1: validate the id format
function validateUserId(req, res, next) {
if (!/^[0-9a-fA-F]{24}$/.test(req.params.id)) {
return res.status(400).json({ error: 'Invalid user ID format' });
}
next();
}
// Step 2: load the user, attach to req
async function loadUser(req, res, next) {
try {
const user = await findUserById(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
req.user = user; // hand off to the next function
next();
} catch (err) {
next(err); // forward to the error handler
}
}
// Step 3: respond
function sendUser(req, res) {
res.json(req.user);
}
app.get('/users/:id', validateUserId, loadUser, sendUser);
Each function does exactly one thing, which makes them reusable — validateUserId and loadUser can be shared across GET, PUT, and DELETE routes for the same resource.
Modular Routers
Piling every route into one file stops scaling quickly. express.Router() creates a mini-application — its own middleware and routes — that you can define in a separate file and mount at a path prefix.
File: routes/userRoutes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
router.get('/', userController.getAllUsers);
router.post('/', userController.createUser);
router.get('/:id', userController.getUserById);
router.put('/:id', userController.updateUser);
router.delete('/:id', userController.deleteUser);
module.exports = router;
File: app.js
const express = require('express');
const userRoutes = require('./routes/userRoutes');
const productRoutes = require('./routes/productRoutes');
const app = express();
app.use(express.json());
// Mount routers at their prefixes
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);
app.listen(3000, () => console.log('Listening on 3000'));
Because the router is mounted at /api/users, its internal router.get('/') answers GET /api/users, and router.get('/:id') answers GET /api/users/:id. Each resource gets its own file, and the main app stays a short table of contents.
💡 Router-level middleware
A router can carry its own middleware with router.use(...). Put an auth guard there and it protects every route on that router without repeating it — the perfect place to scope concerns to one resource.
Writing Custom Middleware
A middleware is just a function with the signature (req, res, next). The most flexible pattern is a middleware factory: a function that takes options and returns a configured middleware. This lets you reuse one middleware with different settings on different routes.
// A configurable rate limiter (in-memory demo)
function rateLimit({ windowMs = 60_000, max = 100, message = 'Too many requests' } = {}) {
const hits = new Map();
return (req, res, next) => {
const ip = req.ip;
const now = Date.now();
const entry = hits.get(ip);
if (!entry || now - entry.start > windowMs) {
hits.set(ip, { count: 1, start: now });
return next();
}
entry.count++;
if (entry.count > max) {
return res.status(429).json({ error: message });
}
next();
};
}
// Generous limit globally...
app.use(rateLimit({ windowMs: 15 * 60_000, max: 100 }));
// ...but strict on login
app.use('/api/login', rateLimit({ windowMs: 60 * 60_000, max: 5 }));
⚠️ In-memory state doesn't survive scaling
The Map above lives in one process. Run two instances behind a load balancer and each keeps its own counts. For real deployments use a shared store (Redis) via a library like express-rate-limit. The factory pattern, though, is exactly how those libraries work internally.
The same factory idea powers authentication guards, feature flags, and role checks — anywhere you want "the same behavior, tuned per route."
Error-Handling Middleware
Error-handling middleware is special: Express recognizes it by its four arguments — (err, req, res, next). When any middleware calls next(err) with an argument, Express skips all remaining regular middleware and jumps straight to the first error handler.
The professional pattern pairs custom error classes with a single central handler. The classes carry a status code and mark expected ("operational") errors so you can safely show their message to clients:
// errors.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(msg = 'Resource not found') { super(msg, 404); }
}
class ValidationError extends AppError {
constructor(msg = 'Validation failed') { super(msg, 400); }
}
module.exports = { AppError, NotFoundError, ValidationError };
// A route that throws, then forwards to the handler
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await findUserById(req.params.id);
if (!user) throw new NotFoundError(`User ${req.params.id} not found`);
res.json(user);
} catch (err) {
next(err); // hand off to the error-handling middleware
}
});
// The ONE central error handler — registered LAST
app.use((err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
if (process.env.NODE_ENV === 'development') {
return res.status(err.statusCode).json({
status: err.status, message: err.message, stack: err.stack
});
}
// Production: only leak messages for trusted, operational errors
if (err.isOperational) {
return res.status(err.statusCode).json({ status: err.status, message: err.message });
}
console.error('UNEXPECTED ERROR', err);
res.status(500).json({ status: 'error', message: 'Something went wrong' });
});
💡 Register the error handler last
Because middleware runs top to bottom, the error handler must be the last app.use() — after all routes. Only then can every route's next(err) reach it.
Hands-on Exercise
🏋️ A modular users API with validation and error handling
Objective: combine everything above into a small, well-structured API.
Instructions:
- Create
routes/userRoutes.jsusingexpress.Router()withGET /,POST /, andGET /:id. - Write a
validateIdmiddleware that returns400if:idis not a positive integer, usingnext()on success. - In the
GET /:idhandler, throw aNotFoundErrorwhen the user is missing and forward it withnext(err). - Mount the router at
/api/usersand add a central error handler last. - Test
/api/users/abc(expect 400),/api/users/999(expect 404), and/api/users/1(expect 200).
💡 Hint
Validate the id with /^\d+$/.test(req.params.id). Keep the error classes in their own module and require them where thrown. Remember: the error handler needs all four parameters, even if you don't use next.
✅ Sample solution
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const { NotFoundError } = require('../errors');
const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
function validateId(req, res, next) {
if (!/^\d+$/.test(req.params.id)) {
return res.status(400).json({ error: 'Invalid id' });
}
next();
}
router.get('/', (req, res) => res.json(users));
router.post('/', (req, res) => {
const user = { id: users.length + 1, name: req.body.name };
users.push(user);
res.status(201).json(user);
});
router.get('/:id', validateId, (req, res, next) => {
const user = users.find(u => u.id === Number(req.params.id));
if (!user) return next(new NotFoundError(`User ${req.params.id} not found`));
res.json(user);
});
module.exports = router;
// app.js
const express = require('express');
const userRoutes = require('./routes/userRoutes');
const app = express();
app.use(express.json());
app.use('/api/users', userRoutes);
app.use((err, req, res, next) => {
const code = err.statusCode || 500;
res.status(code).json({ error: err.message });
});
app.listen(3000, () => console.log('Listening on 3000'));
🎯 Quick Quiz
Question 1: Where does Express put the value 42 from a request to /items/42 defined as /items/:id?
Question 2: How does Express know a middleware is an error handler?
Question 3: What is the main benefit of express.Router()?
Best Practices
✅ Do
- Use route parameters to identify resources and query parameters for optional filters/pagination.
- Split routes into per-resource routers and mount them under an
/apiprefix. - Break complex routes into small chained handlers you can reuse.
- Wrap async handlers in
try/catch(or a wrapper) and forward errors withnext(err). - Register one central error handler last, and distinguish operational from unexpected errors.
⚠️ Don't
- Don't trust query values as numbers — they're strings; parse and default them.
- Don't scatter error formatting across every route; centralize it.
- Don't leak stack traces or internal messages to clients in production.
- Don't rely on in-memory middleware state once you run more than one instance.
Summary & Quiz
🎉 Key Takeaways
- Routes follow
app.METHOD(PATH, HANDLER); pick accurate status codes. - Route parameters (
req.params) identify resources; query parameters (req.query) carry optional data and are always strings. - Chaining handlers keeps each step focused and reusable.
express.Router()turns a growing app into modular, mountable pieces.- A four-argument, centrally-registered error handler — plus custom error classes — makes failures predictable and safe.
📚 Further Reading
🚀 What's Next?
You can route requests and handle errors cleanly. Next we'll turn responses into rich HTML pages using template engines — EJS, Pug, and Handlebars — so your server can render dynamic views, not just JSON.
🎉 Well routed!
Your API now has structure and guardrails. Let's give it a face.