🛣️ Routing and Middleware
Routing decides where a request goes; middleware decides what happens to it along the way. Together they are the two ideas that make Express feel like a framework rather than a pile of if statements. Get these two right and everything else in Express clicks into place.
Week 7 · Day 2 (Tuesday: Express.js Basics) · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Define routes by HTTP method and path, and capture dynamic values with route parameters
- Read query-string parameters from
req.queryand validate them safely - Split a growing app into modular route files with
express.Router - Explain the
(req, res, next)signature and hownext()moves a request down the pipeline - Use built-in, custom, and third-party middleware, and write an error-handling middleware
- Order middleware correctly so parsing, auth, routes, and error handling all fire at the right moment
Estimated Time: 75 minutes
Practice: Build a modular blog API with a Router plus custom logging and auth middleware.
In This Lesson
Routing Basics
A route tells Express: "when a request arrives with this HTTP method at this path, run this function." The shape is always the same:
app.METHOD(PATH, HANDLER);
// │ │ │
// │ │ └── (req, res) => { ... } — what to do
// │ └───────── the URL path, e.g. '/users'
// └──────────────── get, post, put, patch, delete, ...
Here are the four verbs you'll use most, each mapped to a slice of CRUD (create, read, update, delete):
const express = require('express');
const app = express();
app.use(express.json());
app.get('/users', (req, res) => { // READ the collection
res.json([{ id: 1, name: 'Ada' }]);
});
app.post('/users', (req, res) => { // CREATE
res.status(201).json({ created: req.body });
});
app.put('/users/:id', (req, res) => { // UPDATE (full replace)
res.json({ updated: req.params.id, with: req.body });
});
app.delete('/users/:id', (req, res) => { // DELETE
res.status(204).end(); // 204 = success, no body
});
app.listen(3000, () => console.log('http://localhost:3000'));
💡 Which HTTP method for which action?
| Method | Meaning | Typical use |
|---|---|---|
GET | Read | Fetch data, never change it |
POST | Create | Add a new resource |
PUT | Replace | Overwrite an existing resource |
PATCH | Modify | Update part of a resource |
DELETE | Remove | Delete a resource |
We'll formalise these into REST conventions in the next lesson. For now, note that a GET should never modify data.
Route & Query Parameters
Real routes need to carry data. Express gives you two distinct channels for it, and mixing them up is a classic beginner slip.
Route parameters — req.params
A colon in the path marks a dynamic segment. Whatever the client puts there is captured into req.params. Use these to identify a specific resource.
app.get('/users/:userId/books/:bookId', (req, res) => {
const { userId, bookId } = req.params;
res.send(`User ${userId}, Book ${bookId}`);
});
// Request: GET /users/34/books/8989
// req.params = { userId: '34', bookId: '8989' } ← always strings
Query parameters — req.query
Everything after the ? in a URL is parsed into req.query. Use these for optional refinements: filtering, sorting, and pagination.
app.get('/products', (req, res) => {
// Set defaults and convert types — query values are always strings!
const category = req.query.category; // optional
const page = Math.max(1, parseInt(req.query.page || '1', 10));
const limit = Math.min(100, parseInt(req.query.limit || '10', 10));
res.json({ category, page, limit });
});
// Request: GET /products?category=books&page=2&limit=20
// req.query = { category: 'books', page: '2', limit: '20' }
⚠️ Everything from the URL is a string — and it's untrusted input
req.params.id and req.query.limit arrive as strings, so convert with parseInt/Number before doing maths. And because a client can send anything, always validate and clamp values (as the Math.min/Math.max above does) before trusting them.
| Route parameter | Query parameter | |
|---|---|---|
| Where | In the path: /users/:id | After ?: /users?role=admin |
| Read from | req.params | req.query |
| Usually | Required — identifies a resource | Optional — filters/sorts |
Modular Routes with Router
Piling every route into app.js works until it doesn't. express.Router() is a mini-application you can define routes on, then mount at a base path. It's how you keep a growing app organised by resource.
// routes/users.js — a self-contained router
const express = require('express');
const router = express.Router();
// Paths here are RELATIVE to wherever this router gets mounted:
router.get('/', (req, res) => res.send('List all users'));
router.get('/:id', (req, res) => res.send(`Get user ${req.params.id}`));
router.post('/', (req, res) => res.status(201).send('Create user'));
module.exports = router;
// app.js — mount the router under a base path
const express = require('express');
const app = express();
const userRoutes = require('./routes/users');
app.use('/users', userRoutes); // router's '/' becomes '/users'
// router's '/:id' becomes '/users/:id'
app.listen(3000);
Mounting at /users means every route inside users.js is automatically prefixed. Add a products.js router the same way and your app stays tidy no matter how many resources you add.
📖 Routers also carry their own middleware
A router isn't just routes — you can attach middleware to it that runs only for its paths. Mount an auth check on an admin router and every route under it is protected, with no repetition:
const router = express.Router();
router.use(requireAdmin); // guards every route below
router.get('/dashboard', showDash);
router.get('/reports', showReports);
What Is Middleware?
A middleware function is any function with the signature (req, res, next) that sits in the request pipeline. Each one can read or modify the request and response, and then either pass control onward with next() or end the cycle by sending a response.
function myMiddleware(req, res, next) {
// 1. Do some work (log, parse, check auth, attach data...)
console.log(`${req.method} ${req.url}`);
// 2a. Pass control to the next function in the pipeline:
next();
// 2b. ...OR end the cycle instead of calling next():
// res.status(401).send('Not allowed');
}
🏭 The assembly-line analogy
Picture a factory line. The raw material (the incoming request) enters one end. Each station (middleware) does one job — stamp it, inspect it, attach a label — then either passes it to the next station with next() or pulls it off the line (sends a response). At the end, the finished product (the response) ships back to the client. Skip a next() with no response and the item just sits there forever — a hung request.
Here's the whole request lifecycle as a pipeline — the diagram worth memorising:
Kinds of Middleware
1. Built-in middleware
Express ships three you'll use constantly — no installs needed:
app.use(express.json()); // parse JSON bodies → req.body
app.use(express.urlencoded({ extended: true })); // parse HTML form bodies → req.body
app.use(express.static('public')); // serve files from ./public
💡 The extended option
express.urlencoded({ extended: true }) lets form data contain nested objects and arrays. extended: false uses Node's simpler query-string parser. For most apps, true is the friendlier choice.
2. Custom middleware
Write your own for anything cross-cutting. A request logger is the classic first example:
// A logger that also stamps each request with a timestamp
function requestLogger(req, res, next) {
req.requestTime = new Date().toISOString();
console.log(`[${req.requestTime}] ${req.method} ${req.originalUrl}`);
next(); // don't forget this, or the request hangs!
}
app.use(requestLogger); // runs for every request
Middleware can also guard a single route — just pass it before the handler:
function requireApiKey(req, res, next) {
if (req.get('X-API-Key') !== process.env.API_KEY) {
return res.status(401).json({ error: 'Invalid API key' });
}
next(); // key is valid — carry on
}
// Only /admin is protected; other routes are untouched:
app.get('/admin', requireApiKey, (req, res) => {
res.json({ secret: 'the launch codes' });
});
3. Third-party middleware
The ecosystem is Express's superpower. Install a package and app.use() it:
| Package | Purpose | Install |
|---|---|---|
morgan | HTTP request logging | npm i morgan |
cors | Cross-Origin Resource Sharing | npm i cors |
helmet | Security response headers | npm i helmet |
compression | Gzip responses | npm i compression |
multer | File uploads (multipart) | npm i multer |
const morgan = require('morgan');
const helmet = require('helmet');
const cors = require('cors');
app.use(helmet()); // sensible security headers
app.use(cors()); // allow cross-origin requests
app.use(morgan('dev')); // concise coloured request logs
Error-Handling Middleware
Error handlers are special: they take four arguments, (err, req, res, next). Express recognises the extra err parameter and routes errors to them. They belong last, after all routes.
// A route that deliberately fails — pass the error to next():
app.get('/boom', (req, res, next) => {
const err = new Error('Something broke');
err.statusCode = 400;
next(err); // hands off to the error handler below
});
// The error handler — note the FOUR parameters:
app.use((err, req, res, next) => {
console.error(err.stack);
const status = err.statusCode || 500;
res.status(status).json({
error: err.message || 'Internal Server Error'
});
});
✅ async handlers: pass errors to next
In an async handler, a rejected promise won't reach your error middleware unless you forward it. Wrap the body in try/catch and call next(err):
app.get('/users/:id', async (req, res, next) => {
try {
const user = await db.findUser(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
} catch (err) {
next(err); // sends it to the error-handling middleware
}
});
Pair the error handler with a catch-all 404 for routes that matched nothing:
// 404 — after all real routes, before the error handler:
app.use((req, res) => {
res.status(404).json({ error: `No route for ${req.method} ${req.originalUrl}` });
});
Middleware Order
Because middleware runs top-to-bottom in the order you register it, ordering is not a style choice — it's correctness. This is the canonical arrangement:
const app = express();
// 1. Security & logging — first, so they cover everything
app.use(helmet());
app.use(morgan('dev'));
// 2. Body parsing — before any route that reads req.body
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 3. Static files
app.use(express.static('public'));
// 4. Your application routes
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);
// 5. Catch-all 404 — after every real route
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// 6. Error handler — the very last thing
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal server error' });
});
⚠️ Break the chain and the request hangs
If a middleware neither calls next() nor sends a response, the request never finishes — the client waits forever. Every path through a middleware must end in exactly one of those two actions.
// 🐛 BUG: nothing happens when the condition is false
app.use((req, res, next) => {
if (req.headers.authorization) next();
// else... the request just hangs
});
// ✅ FIX: always resolve one way or the other
app.use((req, res, next) => {
if (req.headers.authorization) return next();
res.status(401).send('Authorization required');
});
Practice & Quiz
🏋️ Exercise 1: A modular posts router
Goal: Create routes/posts.js exporting a router with GET / (list), GET /:id (one), and POST / (create, requires a JSON title). Mount it at /api/posts in app.js.
💡 Hint
Define paths on the router relative to the mount point — the router's '/' becomes /api/posts. Register express.json() in app.js before mounting, so the POST route can read req.body.
✅ Solution
// routes/posts.js
const express = require('express');
const router = express.Router();
let posts = [{ id: 1, title: 'Hello' }];
router.get('/', (req, res) => res.json(posts));
router.get('/:id', (req, res) => {
const post = posts.find(p => p.id === Number(req.params.id));
if (!post) return res.status(404).json({ error: 'Post not found' });
res.json(post);
});
router.post('/', (req, res) => {
const { title } = req.body;
if (!title) return res.status(400).json({ error: 'title is required' });
const post = { id: posts.length + 1, title };
posts.push(post);
res.status(201).json(post);
});
module.exports = router;
// app.js
const express = require('express');
const app = express();
app.use(express.json());
app.use('/api/posts', require('./routes/posts'));
app.listen(3000, () => console.log('http://localhost:3000'));
🏋️ Exercise 2: A timing middleware
Goal: Write custom middleware that records how long each request takes and logs it after the response is sent.
💡 Hint
Capture the start time, then listen for the response's finish event with res.on('finish', ...) to measure the elapsed time.
✅ Solution
function timing(req, res, next) {
const start = Date.now();
res.on('finish', () => {
const ms = Date.now() - start;
console.log(`${req.method} ${req.originalUrl} → ${res.statusCode} (${ms}ms)`);
});
next();
}
app.use(timing);
🎯 Quick Quiz
Question 1: In /users/:id, where does the value of :id arrive?
Question 2: How does Express know a function is an error-handling middleware?
Question 3: A middleware neither calls next() nor sends a response. What happens?
Best Practices & Pitfalls
✅ Do
- Split routes into
express.Routermodules, one per resource - Register body parsers and security middleware before your routes
- Validate and type-convert everything from
req.paramsandreq.query - Give every request path exactly one ending: a
next()or a response - Put the 404 handler after routes, and the four-argument error handler dead last
- Forward async errors with
try/catchandnext(err)
❌ Don't
- Define the error handler before your routes — it won't catch them
- Trust query/route values without validating them (they're user input)
- Use a
GETroute to change data — that's what POST/PUT/DELETE are for - Forget
next()in a pass-through middleware
⚠️ "Cannot set headers after they are sent"
Error: Cannot set headers after they are sent to the client
This means one request tried to send two responses — often calling res.json() and then continuing to more code that responds again. Add return before your response calls in branches: return res.status(404).json(...).
Summary
🎉 Key Takeaways
- Routes map an HTTP method + path to a handler:
app.get('/path', handler) - Route parameters (
req.params) identify resources; query parameters (req.query) refine them — both are untrusted strings express.Routersplits a big app into modular, mountable route files- Middleware is a
(req, res, next)function; each one callsnext()or ends the response - Error handlers take four arguments and go last; order is correctness, not style
📚 Additional Resources
- Express — Routing guide
- Express — Using middleware
- Express — Writing middleware
- Express — Error handling
🚀 What's Next?
You can now route requests and shape the pipeline they flow through. Next we zoom in on the two objects every handler receives — reading everything the client sent and crafting exactly the reply you want — in Request and Response Objects.
🎉 The pipeline is yours to command!
Routing and middleware are the backbone of every Express app — you now understand both.