Week 2: Express Fundamentals & Routing

Week 1 gave you a working TypeScript project and a mental model for Node's event loop — this week you build your first real HTTP server on top of both, using Express. You'll learn how the middleware chain and the request/response cycle actually work, how to organize routes into modular express.Router() instances instead of one giant file, how to read route parameters and query strings safely, and how to centralize error handling instead of wrapping every handler in its own try/catch. Every route you write from here on — the REST endpoints in Week 3, the Prisma-backed persistence in Weeks 4 and 5, the auth middleware in Week 6 — is built on the patterns you establish this week.

Module 2 of 22 Week 2 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain the Express middleware chain and how next() drives the request/response cycle
  • Organize routes into modular express.Router() instances and mount them on an app
  • Write a centralized error-handling middleware instead of scattering try/catch everywhere

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.

src/main.ts
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.

middleware ordering matters
// 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
});
A route handler is just middleware with three arguments instead of a full pipeline

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.

src/routes/users.ts
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;
src/main.ts
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.

Router-level middleware

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.

src/routes/posts.ts
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.

src/lib/errors.ts
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`);
  }
}
src/middleware/error-handler.ts
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" });
};
src/main.ts
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:

src/lib/async-handler.ts
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 fixes this automatically

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

Hands-on

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:

  1. Create a notesRouter in its own file with GET / (list all notes), GET /:id (one note), POST / (create), and DELETE /:id (remove), backed by an in-memory array of { id: string; title: string; body: string }.
  2. Mount the router at /api/notes in main.ts, with a request-logging middleware registered before it.
  3. Support GET /api/notes?search=term that filters notes whose title includes term (case-insensitive), validating that search, if present, is a non-empty string.
  4. Add an AppError/NotFoundError pair and throw NotFoundError from GET /:id and DELETE /:id when the id doesn't exist, instead of manually writing a 404 response inline.
  5. 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.
  6. Test every route with curl or a REST client, including at least one request that deliberately triggers each error path.
Hint

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?

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?

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?

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?

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.