If you have ever opened the raw node:http module and tried to write a real server with it, you remember the moment. You wrote http.createServer((req, res) => { ... }). Then you tried to read JSON from the body and discovered there is no req.body. You parsed it with streams. Then you tried to route a POST /users differently from GET /users and ended up with a giant if/else ladder. Then you added auth, logging, error handling, CORS, and the file became unreadable.
Node gave you a great low level engine, but a real web server needs a friendly chassis on top. Express is that chassis.
That is the gap Express fills.
What is Express, really
Think of Express as a small assembly line for HTTP requests. A request walks in one end. It passes through a series of stations, where each station does one thing: parse the body, check the cookie, log the URL, look up the user, validate the input. At the end, a route handler builds a response. The response walks back out the door.
Each station is a middleware. The whole assembly line is the app. You decide which stations to install, in what order. That single idea, plus a list of HTTP verb shortcuts, is essentially the entire framework.
Two ideas drive everything:
-
Middleware chain. Every request passes through a sequence of functions. Each one calls
next()to pass control along, or sends a response to stop. - Unopinionated. Express does not tell you how to structure your app. It gives you a tiny, sharp tool and gets out of your way. That is its strength and its trap.
That is the whole vibe.
Let's pretend we are building one
We want a friendly Node.js framework that handles routing, body parsing, and middleware composition without inventing a new language. We will call it Express.js.
For the running example, we are building a tiny Stickies API: tiny sticky notes, owned by users, taggable, searchable. Small, but it lets us touch every important corner.
Decision 1: Hello world in five lines
import express from "express";
const app = express();
app.get("/", (req, res) => {
res.send("Hello, sticky notes.");
});
app.listen(3000, () => console.log("listening on :3000"));
That is a working web server. Read it like a sentence: when a GET request comes in for /, send back a string. Listen on port 3000.
The two things every senior should know about that snippet:
-
app.get("/", handler)is sugar for "add a middleware that only runs forGET /". -
res.send()auto setsContent-Typebased on the value (string, Buffer, JSON, etc). Useres.json(obj)when you want to be explicit and forceapplication/json.
Decision 2: Routes, the verbs and the paths
Every HTTP verb has a method on the app:
app.get ("/stickies", listStickies);
app.post ("/stickies", createSticky);
app.get ("/stickies/:id", readSticky);
app.patch ("/stickies/:id", updateSticky);
app.delete("/stickies/:id", deleteSticky);
app.all ("/stickies/:id/lock", lockHandler); // any verb
The colon prefix marks a route parameter. Inside the handler, you read it from req.params:
function readSticky(req, res) {
const { id } = req.params; // string
const note = db.getSticky(id);
if (!note) return res.status(404).json({ error: "not found" });
res.json(note);
}
Three sources of input every Express handler reads from:
req.params // path params: /stickies/:id -> { id: "42" }
req.query // query string: ?tag=urgent -> { tag: "urgent" }
req.body // request body (after a parser middleware sets it)
Plus the headers and cookies:
req.headers["authorization"];
req.cookies?.session; // requires cookie-parser middleware
Modern advice: types for req.body, req.params, and req.query should come from a Zod schema validated at the start of the handler, not from hand annotated TypeScript generics. We will see this in a moment.
Decision 3: Middleware, the soul of Express
A middleware is a function with the shape (req, res, next) => void. It can:
- Read or modify the request.
- Write a response and end the chain.
- Call
next()to pass to the next middleware. - Call
next(err)to skip ahead to error middleware.
function logger(req, res, next) {
const start = Date.now();
res.on("finish", () => {
console.log(`${req.method} ${req.url} ${res.statusCode} ${Date.now() - start}ms`);
});
next();
}
app.use(logger);
app.use(...) mounts a middleware that runs on every request. You can also mount per path:
app.use("/api", apiAuth); // only for /api/*
Order matters. A request flows top to bottom through the middleware list, and the first one to write a response wins. This is why the order of app.use calls is your program's pipeline.
A typical production app stacks them like this:
import express from "express";
import helmet from "helmet";
import cors from "cors";
import compression from "compression";
import morgan from "morgan";
const app = express();
app.use(helmet()); // security headers
app.use(cors({ origin: process.env.WEB_ORIGIN, credentials: true }));
app.use(compression()); // gzip / brotli
app.use(morgan("tiny")); // request logging
app.use(express.json({ limit: "1mb" })); // parse JSON bodies
app.use(express.urlencoded({ extended: true })); // parse form bodies
// your routes
app.use("/api/stickies", stickiesRouter);
// error middleware (last)
app.use(errorHandler);
That stack is essentially the same in every Express app on the planet. Memorize the shape.
Decision 4: Built in body parsers (no more body-parser)
Once upon a time you installed body-parser. Now Express ships JSON and URL-encoded parsers built in:
app.use(express.json({ limit: "1mb" }));
app.use(express.urlencoded({ extended: true }));
app.use(express.text()); // text bodies
app.use(express.raw()); // Buffer bodies
Always set a limit. The default is 100KB and that is fine for most endpoints, but explicit beats default. An unbounded JSON parser is a denial of service waiting to happen.
For file uploads, reach for Multer (multipart/form-data) or stream the body yourself if it is a single file. Never read uploads into memory for big files.
Decision 5: Routers for modular structure
A Router is a mini app. Same .get, .post, .use API. You compose them with app.use(prefix, router).
// routes/stickies.ts
import { Router } from "express";
const r = Router();
r.get("/", listStickies);
r.post("/", createSticky);
r.get("/:id", readSticky);
r.patch("/:id", updateSticky);
export default r;
// app.ts
import stickies from "./routes/stickies";
app.use("/api/stickies", stickies);
Senior level habits:
-
One router per resource. A
stickies.tsrouter, ausers.tsrouter, atags.tsrouter. -
Mount under a versioned prefix.
app.use("/api/v1", apiV1)so future you can add v2 without a rewrite. -
Group middleware on the router, not on every route.
r.use(authRequired)at the top of an admin router runs once per request.
A full senior style folder layout for a non-trivial app:
src/
app.ts -> app composition
server.ts -> listen + graceful shutdown
routes/
stickies.ts
users.ts
controllers/
stickies.controller.ts
services/
stickies.service.ts
middleware/
auth.ts
error.ts
rate-limit.ts
schemas/
sticky.schema.ts -> Zod schemas
lib/
db.ts
logger.ts
Express will not enforce this. It is convention. Pick one and stick with it.
Decision 6: Async handlers (the Express 5 superpower)
In Express 4, an async route that threw or rejected would crash the process unless you wrapped it in asyncHandler or installed express-async-errors. It was the single most common bug in the Express world.
Express 5 (released in October 2024) fixes this. Async route handlers and middleware that return a rejected Promise now automatically forward to the error middleware. You no longer need a wrapper.
// works correctly in Express 5
r.get("/:id", async (req, res) => {
const sticky = await db.stickies.findUnique({ where: { id: req.params.id } });
if (!sticky) throw new NotFoundError("sticky not found");
res.json(sticky);
});
If a senior interview asks you "how do you handle async errors in Express", the modern answer in 2026 is "I use Express 5 and I just throw or reject. The framework forwards to my error middleware."
If you are stuck on Express 4, the patterns:
// option 1: a wrapper
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
r.get("/:id", wrap(async (req, res) => { /* ... */ }));
// option 2: a single import
import "express-async-errors"; // monkey patches Express to handle async
But upgrade. Express 5 is stable and the migration is small.
Decision 7: Error handling middleware, the four argument signature
An error handler is a middleware with four arguments instead of three. Express uses the arity to find them:
function errorHandler(err, req, res, next) {
// ...
}
Mount it last. Anything that calls next(err) (or throws inside an async handler in Express 5) lands here.
A real error middleware that maps known errors to statuses, logs unknown ones, and never leaks internals:
import { ZodError } from "zod";
class HttpError extends Error {
constructor(public status: number, message: string, public code?: string) {
super(message);
}
}
function errorHandler(err, req, res, next) {
if (err instanceof ZodError) {
return res.status(422).json({
error: "Invalid input",
issues: err.issues,
});
}
if (err instanceof HttpError) {
return res.status(err.status).json({ error: err.message, code: err.code });
}
req.log?.error({ err }, "Unhandled error");
res.status(500).json({ error: "Internal server error" });
}
Senior level rule: the error middleware is the only place that decides the status and the body of an error response. Handlers throw, middleware translates. The system stays consistent.
Decision 8: Validate every input with Zod
Express does not validate anything for you. Use a Zod schema at the top of every handler, and you get safety, types, and a clear failure mode for free.
import { z } from "zod";
const CreateSticky = z.object({
body: z.string().min(1).max(500),
tags: z.array(z.string()).default([]),
color: z.enum(["yellow", "pink", "blue"]).default("yellow"),
});
r.post("/", async (req, res) => {
const data = CreateSticky.parse(req.body); // throws ZodError on failure
const sticky = await db.stickies.create({ data });
res.status(201).json(sticky);
});
When parse throws, your error middleware turns it into a 422 with the issues. Hands free.
If you want a one liner middleware version:
const validate = (schemas: { body?: ZodSchema; query?: ZodSchema; params?: ZodSchema }) =>
(req, res, next) => {
if (schemas.body) req.body = schemas.body.parse(req.body);
if (schemas.query) req.query = schemas.query.parse(req.query);
if (schemas.params) req.params = schemas.params.parse(req.params);
next();
};
r.post("/", validate({ body: CreateSticky }), createSticky);
Decision 9: Auth, the modern Node story
Express has zero auth out of the box, on purpose. Three patterns you will see:
-
Session cookies with a store like
connect-redis. Useexpress-sessionpluscookie-parser. The classic, still excellent for first party web apps. -
JWT in HttpOnly cookies, verified with a tiny middleware that reads the cookie, verifies the signature, and attaches
req.user. - Auth.js / Lucia / hosted services (Clerk, Kinde, WorkOS) when you want to stop thinking about auth.
A minimal cookie session example:
import session from "express-session";
import RedisStore from "connect-redis";
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET!,
cookie: {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 1000 * 60 * 60 * 24 * 7,
},
resave: false,
saveUninitialized: false,
}));
function authRequired(req, res, next) {
if (!req.session.userId) return res.status(401).json({ error: "unauthorized" });
next();
}
app.use("/api/admin", authRequired);
Senior level rule: never trust the client about auth. The session check happens server side, on every protected request, in middleware.
Decision 10: Security middleware that should be on by default
A short checklist most teams skip and regret later:
import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";
import hpp from "hpp";
app.disable("x-powered-by"); // do not advertise the framework
app.set("trust proxy", 1); // honor X-Forwarded-* behind a proxy
app.use(helmet()); // sets a dozen security headers
app.use(cors({ origin: process.env.WEB_ORIGIN, credentials: true }));
app.use(hpp()); // protects against parameter pollution
app.use("/api/", rateLimit({
windowMs: 60_000,
limit: 120,
standardHeaders: "draft-7",
legacyHeaders: false,
}));
The senior level extras:
-
Set a body size limit on
express.json({ limit }). -
Set timeouts on the server:
server.headersTimeout = 60_000; server.requestTimeout = 30_000;. - Validate every input. No exceptions.
- Use parameterized DB queries. No string concatenation.
Decision 11: TypeScript with Express, the friendly way
You can type Express by hand, or you can let inference do most of the work via Zod and a tiny helper:
import { Request, Response, NextFunction, RequestHandler } from "express";
// hand typed handler
const createSticky: RequestHandler<{}, Sticky, CreateStickyDto> =
async (req, res) => {
const sticky = await service.create(req.body);
res.status(201).json(sticky);
};
The cleaner pattern in 2026: stop typing req.body by hand, let Zod validate and infer:
const CreateSticky = z.object({ body: z.string(), tags: z.array(z.string()) });
type CreateStickyInput = z.infer<typeof CreateSticky>;
r.post("/", async (req, res) => {
const data: CreateStickyInput = CreateSticky.parse(req.body);
// ...
});
For shared client / server typed contracts, look at ts-rest or tRPC. They put the schema in one place, both sides import it, no drift.
Decision 12: Testing Express handlers
Two patterns:
Supertest, the classic
Drives your Express app in memory, no real server needed. Works with Vitest or Jest.
import request from "supertest";
import { app } from "../src/app";
it("creates a sticky", async () => {
const res = await request(app)
.post("/api/stickies")
.send({ body: "Buy milk" })
.expect(201);
expect(res.body.body).toBe("Buy milk");
});
Real HTTP for integration tests
When you want the full stack (rate limit, auth, real database), run the app on an ephemeral port and hit it with fetch. Slower, more realistic. Pair with a transactional rollback per test or a Postgres / Mongo Memory Server.
The boundary that gives the best return: one Supertest test per route. Hit the happy path, hit the validation error, hit the auth error. That covers most regressions in five minutes.
Decision 13: Graceful shutdown and observability
Senior level details that beginners skip:
const server = app.listen(3000);
function shutdown(signal: string) {
console.log(`received ${signal}, shutting down`);
server.close(() => {
db.disconnect().finally(() => process.exit(0));
});
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
A long running process should drain in flight requests on SIGTERM, close the database, and only then exit. Without this, deploys interrupt users mid request.
For logs and metrics: pick pino (fast structured logger) and OpenTelemetry (tracing and metrics). Drop a request id middleware so every log line is correlatable.
Decision 14: When to pick Express, when to pick something else
Senior engineers know when to reach for a different tool. The 2026 landscape:
- Express: the default for "I want a small server, I want to control everything". Tons of middleware, tons of tutorials, runs everywhere Node runs. Slower than the new generation, but only by milliseconds. For 99% of apps the difference does not matter.
- Fastify: a faster, more opinionated cousin of Express. Native schema validation (via Ajv), plugin system, very good performance. Pick this when throughput matters or you want stricter conventions.
- Hono: tiny, ultra fast, built for the edge (Cloudflare Workers, Bun, Deno, AWS Lambda). Same vibe as Express, modern API. Pick this when you target serverless or the edge.
- NestJS: a full framework on top of Express (or Fastify). Modules, controllers, dependency injection, opinionated patterns. Pick this when the team is large and you want structure for free. (See the NestJS post.)
- tRPC: not a server framework, a typed RPC layer. Sits inside an Express or Fastify app and gives you end to end type safety with no codegen. Pick this when client and server are both TypeScript and you control both.
If you do not know what to pick, pick Express. It is the lingua franca. You can always migrate later.
A peek under the hood
What really happens when a request hits an Express app:
- Node's
httpserver emits a"request"event with the rawreqandres. - Express's app function receives them. It walks its internal router stack (the middleware list).
- For each middleware, Express checks the path and method. If they match, the function runs.
-
next()advances.next(err)skips to the next four argument middleware. A response (res.send,res.json,res.end) ends the chain. - Once the response is committed, Node sends it back to the client and the request lifecycle ends.
That whole loop is built on top of the http module. No magic, no proxy, no codegen. Express is, deep down, a very polished for loop over middleware functions. That clarity is its greatest gift.
Tiny tips that will save you later
- Upgrade to Express 5. Free async error handling, free upgrades.
- Mount middleware in the right order. Helmet first, body parsers next, routes after, error handler last.
- Set
app.disable("x-powered-by"). - Always validate input with Zod. Trust nothing.
- Limit body size.
- One router per resource. One file per router.
- Throw real
Errorobjects withcause. -
Never
console.login production code. Use pino or the framework logger of your choice, with structured fields. -
Add a
/healthendpoint. Your load balancer wants it. - Graceful shutdown on SIGTERM.
- Use Supertest for handler tests. Fast feedback, real HTTP semantics.
-
Generate API docs from Zod schemas with
zod-to-openapi. Free OpenAPI spec, no extra source of truth.
Wrapping up
So that is the whole story. We were tired of fighting raw node:http. We built a tiny chassis that turns the request lifecycle into an assembly line of middleware. Each station does one thing. The first one to write a response stops the line. Routes are middleware that match a verb and a path. Errors are middleware with four arguments. Async errors, in Express 5, just work.
We learned to mount security middleware first, validate every input with Zod, push business logic into services, return errors through a single error handler, and shut down gracefully when the platform asks. We picked routers as our unit of structure. We tested with Supertest. We compared Express to Fastify, Hono, NestJS, and tRPC, and learned when each one wins.
Once that map is in your head, Express stops feeling like "the old way to write a Node server" and starts feeling like the calm, reliable backbone it has been for a decade. It is small enough to learn in an afternoon and deep enough to build a career on.
Happy serving, and may your next() always lead somewhere useful.
Top comments (0)