1. Middleware & the Request/Response Cycle
An Express app is, at its core, a pipeline of functions that each get a chance to
inspect or modify the incoming request before a response goes out. Every one of those
functions — called middleware — receives the same three arguments:
req (the incoming request), res (the outgoing response), and
next (a function that hands control to the next middleware in line). If a
middleware never calls next() and never sends a response, the request
simply hangs forever — this is the single most common bug beginners hit with Express.
import express from "express";
const app = express();
// Built-in middleware: parses JSON request bodies into req.body
app.use(express.json());
// A simple custom middleware: logs every request as it arrives
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
next(); // without this, the request never reaches any route handler
});
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Listening on http://localhost:${PORT}`);
});
Middleware runs in the order it's registered, top to bottom. That
ordering is not cosmetic — it's the whole mechanism. express.json() has to
run before any route handler that reads req.body, because it's the thing
that actually parses the raw request stream into a usable object; register a route
before it and req.body will be undefined in every handler that
comes after. The same logic applies to authentication middleware in Week 6: it has to
run before any route it's meant to protect, so it can reject the request early rather
than letting a protected handler execute first.
// WRONG: route registered before the body parser
app.post("/echo", (req, res) => {
res.json(req.body); // req.body is undefined here
});
app.use(express.json());
// RIGHT: parser first, then routes that depend on it
app.use(express.json());
app.post("/echo", (req, res) => {
res.json(req.body); // works as expected
});
There's no special "route handler" type in Express — app.get("/health", handler) registers handler as middleware scoped to GET /health requests only. Once you internalize that routes and middleware are the same mechanism, patterns like "run this middleware only on this one route" (app.get("/admin", requireAuth, handler)) stop looking like a special case and start looking obvious.
2. Modular Routing with express.Router()
Putting every route in main.ts works for a five-minute demo and falls
apart fast in a real project. express.Router() creates a mini, self-contained
Express app — it has its own .get(), .post(), and its own
middleware chain — that you can build in its own file and mount onto the main app under
a path prefix.
import { Router } from "express";
const usersRouter = Router();
// Because this router is mounted at /api/users in main.ts,
// this handles GET /api/users
usersRouter.get("/", (req, res) => {
res.json({ users: [] });
});
// This handles GET /api/users/:id
usersRouter.get("/:id", (req, res) => {
res.json({ id: req.params.id, name: "Ada Lovelace" });
});
// This handles POST /api/users
usersRouter.post("/", (req, res) => {
const { name } = req.body as { name?: string };
res.status(201).json({ id: "new-id", name });
});
export default usersRouter;
import express from "express";
import usersRouter from "./routes/users.js";
import postsRouter from "./routes/posts.js";
const app = express();
app.use(express.json());
app.use("/api/users", usersRouter);
app.use("/api/posts", postsRouter);
app.listen(3000);
The router itself never needs to know what prefix it's mounted under — it just handles
paths relative to whatever app.use() mounts it on. That decoupling is what
makes routers composable: the same usersRouter could be mounted at
/api/users today and /v2/users tomorrow without touching a
single line inside routes/users.ts. A typical project structure groups one
router per resource, which is exactly the shape you'll extend with real persistence in
Week 4 once each router talks to the database instead of returning hardcoded data.
usersRouter.use(someMiddleware) applies someMiddleware only to requests handled by that router, not the whole app — this is how you'll scope authentication or logging to just the routes that need it, rather than adding conditional checks inside every handler.
3. Route Parameters, Query Strings & Basic Validation
Express exposes two different pieces of the URL on req:
req.params for named segments in the route path itself (like
:id), and req.query for everything after the ?.
Both are always strings (or arrays/objects of strings for repeated or nested query
keys) — Express never converts them to numbers or booleans for you, which is a common
source of subtle bugs if you forget it.
import { Router } from "express";
const postsRouter = Router();
// GET /api/posts/42 -> req.params.id === "42" (a string, not a number)
postsRouter.get("/:id", (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: "id must be a positive integer" });
}
res.json({ id, title: "Understanding Middleware" });
});
// GET /api/posts?page=2&limit=10
postsRouter.get("/", (req, res) => {
const page = Number(req.query.page ?? 1);
const limit = Number(req.query.limit ?? 20);
if (!Number.isInteger(page) || page < 1) {
return res.status(400).json({ error: "page must be a positive integer" });
}
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
return res.status(400).json({ error: "limit must be between 1 and 100" });
}
res.json({ page, limit, posts: [] });
});
export default postsRouter;
This kind of hand-rolled validation is fine for a couple of fields, but it gets
unwieldy fast once a request body has ten fields with different types, optional
properties, and nested objects — that's exactly the problem Week 3 solves with Zod
schemas that validate a whole request shape in one declarative pass instead of a wall
of if statements. For now, the important habit is: never trust
req.params or req.query to already be the type or shape you
expect, because they always arrive as raw strings from the URL.
return res.status(...) inside a handler
Returning the call to res.status().json() isn't about the return value (Express ignores it) — it's a habit that stops execution in that handler immediately, preventing a bug where code after a validation failure runs anyway and tries to call res.json() a second time, which throws "Cannot set headers after they are sent to the client."
4. Centralized Error-Handling Middleware
Express recognizes an error-handling middleware by its arity — it must
declare exactly four parameters, (err, req, res, next), in that order, even
if you never use next. Register it last, after every other
app.use() and route, and any error passed to next(err)
anywhere in your app skips straight to it, instead of you needing a try/catch block
around every single handler.
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
) {
super(message);
this.name = "AppError";
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(404, `${resource} not found`);
}
}
import type { ErrorRequestHandler } from "express";
import { AppError } from "../lib/errors.js";
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({ error: err.message });
}
// Anything that isn't one of our known errors is a bug --
// log it fully but never leak internals to the client.
console.error(err);
res.status(500).json({ error: "Internal server error" });
};
import express from "express";
import usersRouter from "./routes/users.js";
import { errorHandler } from "./middleware/error-handler.js";
import { NotFoundError } from "./lib/errors.js";
const app = express();
app.use(express.json());
app.use("/api/users", usersRouter);
// 404 handler for unmatched routes -- runs if nothing above matched
app.use((req, res, next) => {
next(new NotFoundError("Route"));
});
// Error handler MUST be registered last
app.use(errorHandler);
app.listen(3000);
Inside a synchronous handler, Express 4 automatically catches a thrown error
and forwards it to your error middleware. Inside an async handler, it does
not — a rejected promise from an awaited call that throws will crash the
process unhandled unless you either wrap the call in try/catch and call
next(err) yourself, or wrap the whole handler in a small helper that does
it for you:
import type { Request, Response, NextFunction, RequestHandler } from "express";
export function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>,
): RequestHandler {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// usage:
// usersRouter.get("/:id", asyncHandler(async (req, res) => {
// const user = await findUser(req.params.id); // a throw here is caught and forwarded
// res.json(user);
// }));
Express 5 (released as the current major version) catches rejected promises from async route handlers natively, so asyncHandler becomes unnecessary if you're on Express 5. It's still worth understanding the wrapper pattern, though — you'll see it in most existing Express 4 codebases, and the underlying reason (an unhandled rejection isn't a thrown synchronous error) is the same reasoning from Week 1's discussion of main().catch(...).
5. Hands-on Exercise
Build an in-memory notes API with modular routes and centralized errors
Put middleware, routers, params, and error handling together in one small but real Express app, using an in-memory array as a stand-in for the database you'll add in Week 4.
Requirements:
- Create a
notesRouterin its own file withGET /(list all notes),GET /:id(one note),POST /(create), andDELETE /:id(remove), backed by an in-memory array of{ id: string; title: string; body: string }. - Mount the router at
/api/notesinmain.ts, with a request-logging middleware registered before it. - Support
GET /api/notes?search=termthat filters notes whose title includesterm(case-insensitive), validating thatsearch, if present, is a non-empty string. - Add an
AppError/NotFoundErrorpair and throwNotFoundErrorfromGET /:idandDELETE /:idwhen the id doesn't exist, instead of manually writing a 404 response inline. - Register a centralized error-handling middleware last, plus a catch-all 404 handler for unmatched routes, and confirm both a missing note and a completely unknown route return the correct status codes.
- Test every route with
curlor a REST client, including at least one request that deliberately triggers each error path.
Give each note a unique id with crypto.randomUUID() (built into Node — no extra package needed) instead of an incrementing counter; it's a good habit to build now since you'll swap the in-memory array for a Prisma-backed database in Week 4 without changing how ids are shaped.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What breaks if app.use(express.json()) is registered after your routes instead of before them?
What breaks if app.use(express.json()) is registered after your routes instead of before them?
Middleware runs in registration order, and express.json() is what actually parses the raw request stream into req.body. Any route registered before it executes first, so req.body would still be undefined inside that handler — the parsing middleware never got a chance to run before the route needed the data it produces.
Q2
Why does mounting a router with app.use("/api/users", usersRouter) make the router reusable at a different prefix later, without editing the router's file?
Why does mounting a router with app.use("/api/users", usersRouter) make the router reusable at a different prefix later, without editing the router's file?
Routes defined inside the router (like usersRouter.get("/:id", ...)) are written relative to wherever the router ends up mounted, not to an absolute path baked into the file. The prefix lives entirely in the app.use() call in main.ts, so changing it to /v2/users only requires editing that one line, not anything inside routes/users.ts.
Q3
In Express 4, why does a thrown error inside an async route handler need special handling that a synchronous handler doesn't?
In Express 4, why does a thrown error inside an async route handler need special handling that a synchronous handler doesn't?
Express 4's built-in try/catch around route handlers only catches a synchronous throw. An async function that throws produces a rejected promise instead, which Express 4 was never written to observe — without an explicit .catch(next) (or a wrapper like asyncHandler), that rejection goes unhandled instead of reaching your centralized error middleware.
Q4
Why must an error-handling middleware in Express declare exactly four parameters, (err, req, res, next), even when the body never uses next?
Why must an error-handling middleware in Express declare exactly four parameters, (err, req, res, next), even when the body never uses next?
Express inspects a middleware function's declared parameter count (its arity) to decide whether it's a normal middleware or an error handler — four parameters signals "only call this when next(err) was invoked somewhere upstream." A three-parameter version would be treated as regular middleware and would never receive the error at all, since Express would call it as part of the normal request flow instead.