Write Express route handlers and middleware, and understand how requests flow through them.
Why it matters
Node's built-in http module can run a server, but it leaves you to parse URLs, read bodies and route requests by hand. Express is the most widely used Node web framework. It adds two ideas that almost every back-end framework shares, in any language: routes (which code handles which method and path) and middleware (functions that run on the way to a route, such as logging, parsing JSON or checking a login). Learn them once and you will recognise them in Fastify, Koa, Django, Flask, Rails and ASP.NET.
A minimal server
After npm install express, create index.js:
import express from "express";
const app = express();
app.use(express.json()); // parse JSON request bodies into req.body
const courses = [
{ id: 1, title: "SQL Fundamentals" },
{ id: 2, title: "Back-End Web APIs" },
];
app.get("/courses", (req, res) => {
res.json({ items: courses });
});
app.get("/courses/:id", (req, res) => {
const id = Number(req.params.id);
const course = courses.find((c) => c.id === id);
if (!course) return res.status(404).json({ error: "Course not found." });
res.json(course);
});
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`Listening on http://localhost:${port}`));
Run node index.js, then in another terminal:
curl -i http://localhost:3000/courses/2
curl -i http://localhost:3000/courses/99
Routes and handlers
app.get(path, handler) registers a route: when a GET request matches path, Express calls handler(req, res). There is a method for each verb: app.post, app.patch, app.delete, and so on.
req.paramsholds path parameters. In/courses/:id, a request to/courses/2givesreq.params.id === "2". It is always a string, so convert and validate it.req.queryholds the query string:/courses?level=beginnergivesreq.query.level.req.bodyholds the parsed body, but only if a body-parsing middleware such asexpress.json()ran first.res.status(code)sets the status;res.json(value)sends JSON and ends the response. Send exactly one response per request.return res.status(404)...is a common pattern so the code below does not run too.
Routes are checked in the order you register them. Put /courses/new before /courses/:id, or :id will match the word new.
How route matching works
Under the hood, matching a pattern is simple string work. Split both paths on /, compare segment by segment, and treat any segment starting with : as a named parameter:
// pattern "/courses/:id/lessons", path "/courses/7/lessons"
// segments: ["courses", ":id", "lessons"] vs ["courses", "7", "lessons"]
// -> match, params = { id: "7" }
If the segment counts differ, or a fixed segment differs, there is no match. You will write this function yourself in the activity.
Middleware
A middleware is a function (req, res, next). It can read or change req and res, end the response itself, or call next() to pass control to the next function in the chain. app.use(fn) runs fn for every request; you can also attach middleware to one route.
// Logs every request, then continues.
function logRequests(req, res, next) {
const started = Date.now();
res.on("finish", () => {
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - started}ms`);
});
next();
}
// Stops the chain unless an API key header is present.
function requireApiKey(req, res, next) {
if (req.get("x-api-key") !== process.env.API_KEY) {
return res.status(401).json({ error: "Missing or invalid API key." });
}
next();
}
app.use(logRequests);
app.post("/courses", requireApiKey, (req, res) => {
res.status(201).json({ id: 3, title: req.body.title });
});
The order is the order of registration. logRequests runs first for everything; requireApiKey runs only for POST /courses, and if it sends a 401, the handler never runs.
Error-handling middleware
A middleware with four arguments (err, req, res, next) is an error handler. Express calls it when a handler throws or calls next(err). Register it last:
app.use((err, req, res, next) => {
console.error(err); // full details go to your logs only
res.status(500).json({ error: "Something went wrong." });
});
In Express 5, an async handler that rejects is passed to this handler automatically. In Express 4 you had to wrap async handlers or call next(err) yourself.
Common mistakes
- Forgetting
express.json(), soreq.bodyisundefined. - Forgetting to call
next()in a middleware that does not respond, so the request hangs until it times out. - Sending two responses ("Cannot set headers after they are sent"): usually a missing
return. - Route order: a broad pattern registered before a specific one swallows it.
- Treating
req.params.idas a number without converting and validating it. - Sending error details to the client instead of logging them.
Quick recap
- A route pairs a method and a path with a handler
(req, res). req.params,req.queryandreq.bodyhold the input; all of it must be validated.- Middleware
(req, res, next)runs in order and either responds or callsnext(). - A four-argument middleware registered last handles errors.
Try it: in the Do tab you will write the route matcher and a tiny middleware runner, the two ideas at the heart of Express.