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.
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.body | Parsed, 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".
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
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
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
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
// 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.