Express

Use a router, read parameters and bodies, and understand that middleware order is the whole design.

08of 18
≈ 13Min read
8Parts

The same API, much shorter

Express is a thin layer over node:http: a router, a middleware chain and a handful of helpers on req and res. Everything you learned about status codes and headers still applies underneath.

app.js
import express from "express";

const app = express();
app.use(express.json());                 // parse JSON bodies into req.body

app.get("/doors", (req, res) => res.json(doors));
app.get("/doors/:id", (req, res) => {
  const door = doors.find((d) => d.id === req.params.id);
  if (!door) return res.status(404).json({ error: "Not found" });
  res.json(door);
});

app.listen(3000, () => console.log("http://localhost:3000"));

What it adds to req and res

What it gives you
req.params/doors/:id → { id: "12" }, already decoded
req.query?q=oak&page=2 → { q: "oak", page: "2" } — strings
req.bodyParsed, if a body parser ran first
res.json(value)Sets the content type and serialises
res.status(code)Chainable: res.status(201).json(door)
res.sendStatus(204)Status and a standard body, in one

Middleware is the whole idea

  • next() continues. next(err) jumps to the error handler. Returning without either leaves the client waiting.
  • Send a response or call next() — never both, or you get "Cannot set headers after they are sent".
a function between the request and the answer
function timer(req, res, next) {
  const started = Date.now();
  res.on("finish", () => console.log(req.method, req.url, res.statusCode, Date.now() - started + "ms"));
  next();                 // hand over to the next one — forget this and it hangs
}

app.use(timer);                       // every request
app.use("/admin", requireAdmin);      // only paths under /admin
app.get("/doors", cache(60), list);   // only this route

Order is not a detail

the same four lines, two different servers
app.use(express.json());
app.post("/doors", create);      // req.body is the parsed object

// the other way round:
app.post("/doors", create);      // req.body is undefined — the parser never ran
app.use(express.json());
The 404 goes last

A catch-all app.use((req, res) => res.status(404)…) registered before your routes answers 404 to everything. Register it after every route, and the error handler after that.

The shape of a real app

top to bottom, in order
app.use(helmet());               // security headers
app.use(cors({ origin: "https://domvybe.com" }));
app.use(express.json({ limit: "100kb" }));   // a limit, always
app.use(logger);

app.use("/api/doors", doorsRouter);          // feature routers
app.use("/api/orders", ordersRouter);

app.use((req, res) => res.status(404).json({ error: "Not found" }));

app.use((err, req, res, next) => {           // four arguments = error handler
  console.error(err);
  res.status(err.status ?? 500).json({ error: err.expose ? err.message : "Server error" });
});

Routers keep it readable

doors.routes.js
import { Router } from "express";

export const doorsRouter = Router();

doorsRouter.get("/", list);
doorsRouter.get("/:id", getOne);
doorsRouter.post("/", create);

// mounted at /api/doors, so "/" is /api/doors

Async handlers and the trap

Express 4 does not catch a rejected promise
// in Express 4 this hangs the request — the rejection reaches nobody
app.get("/doors", async (req, res) => {
  const doors = await db.all();    // if this throws…
  res.json(doors);
});

// wrap it
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get("/doors", wrap(async (req, res) => { … }));

// Express 5 forwards rejections to the error handler by itself

Express, or something else

  • Express — the default. Enormous ecosystem, tiny core, everyone knows it.
  • Fastify — faster, schema-based validation and serialisation built in.
  • Hono / Elysia — small and built on web standards; the same code runs on workers and Bun.
  • Nest — opinionated and layered, for large teams that want structure imposed.

Practice