2.1 The Middleware Pipeline: Express's Beating Heart
Here's the thing that surprises most people when they first truly understand Express: an Express application is not a router. It's a pipeline.
The routing is just one thing that happens inside that pipeline. The real architecture — the thing that makes Express, Express — is the middleware chain. Every request enters at one end, passes through a sequence of functions, and exits at the other end with a response. Each function in that sequence gets the same three things: the request object, the response object, and a way to pass control forward.
That last part is the key. Middleware functions don't just receive data — they decide whether to keep the chain moving. Call next() and the request flows to the next function. Don't call it, and the request stops right there (which is useful when you've sent a response). Call next(err) and the chain jumps to error-handling mode.
The Express documentation puts it plainly: "An Express application is essentially a series of middleware function calls executed during the request-response cycle" .
Why This Architecture Matters
The middleware pipeline solves a problem that every web application eventually faces: cross-cutting concerns.
Imagine you're building an API. You need to log every request. You need to authenticate users. You need to parse JSON bodies. You need to handle CORS. You need to rate-limit. None of these things belong to any single route — they apply to every route, or at least large groups of them.
Without middleware, you'd write the same logging code at the top of every handler. Or you'd build some custom abstraction that nobody else understands. With Express, you write a function, plug it into the pipeline, and it runs for every request that matches its mount path.
// A middleware function that logs every request
const logger = (req, res, next) => {
console.log(`${req.method} ${req.originalUrl} — ${new Date().toISOString()}`);
next(); // Pass control to the next function
};
app.use(logger); // Runs for every request
That's the entire concept. A function, three arguments, and a call to next().
The Three Arguments
Every middleware function receives the same three arguments :
req— the request object. An enhanced version of Node's native request, carrying everything about the incoming HTTP request.res— the response object. Also enhanced, with methods for sending data back to the client.next— a function. Calling it passes control to the next middleware in the stack. Calling it with an error passes control to error handlers.
If a middleware function doesn't call next() and doesn't end the request, the request hangs. The client waits forever. This is one of the most common mistakes in Express development, and it's why the pattern is so explicit: you are always in control of whether the chain continues.
The Order Is the Logic
Middleware executes in the order it's registered. This isn't a minor detail — it's the entire mechanism for composing behavior.
app.use(logger); // Runs first
app.use(authenticate); // Runs second, only if logger calls next()
app.use('/api', apiRouter); // Runs third, only for /api paths
If you swap the order, the behavior changes. Authentication before logging means unauthenticated requests never get logged. Logging before authentication means every request, authenticated or not, gets recorded.
The Express documentation states it directly: "The order of middleware loading is important: middleware functions that are loaded first are also executed first" .
This ordering property is what makes the pipeline so powerful. You're not configuring a system — you're composing a sequence of operations. The sequence is the logic.
The Next Function and Its Special Values
next() normally passes control to the next function in the stack. But it accepts two special string arguments that change the flow:
next('route')— skips the remaining callbacks in the current route and moves to the next matching route . Useful for pre-condition checks: "if this condition isn't met, don't even bother with the rest of this route's handlers."next('router')— exits the current router entirely and passes control back to the parent application . Useful for early exits from a router when the request doesn't belong there.
Anything else passed to next() is treated as an error, and the chain jumps to error-handling middleware.
app.get('/user/:id', (req, res, next) => {
if (!req.user) {
return next('route'); // Skip to the next route
}
// ... handle the request
}, (req, res, next) => {
// This only runs if the first handler called next()
res.send('User found');
});
The Express 5 Change: Async Errors
In Express 4, an async middleware function that threw an error would silently swallow it unless you wrapped everything in try/catch and called next(err) manually. This was a constant source of bugs.
Express 5 changes that. Middleware functions that return a Promise now have their rejections automatically forwarded to error handlers . The runtime catches the rejection and calls next() with the error for you.
// Express 5: this just works
app.get('/data', async (req, res, next) => {
const data = await fetchFromDatabase(); // If this throws...
res.json(data); // ...the error goes to error handlers
});
This is one of the most practically significant changes in Express 5. It eliminates an entire category of bugs and removes the need for utility wrappers like express-async-handler in most cases.
2.2 The Application Object (app)
When you write const app = express(), you're creating an instance of the Express application. This object is the central hub of your server — it holds configuration, registers middleware, defines routes, and ultimately handles the HTTP server.
What the App Object Does
The application object is the public face of your Express server. Through it, you:
- Register middleware with
app.use() - Define routes with
app.get(),app.post(), and their siblings - Configure settings with
app.set()andapp.enable() - Mount sub-applications by passing another
appinstance toapp.use()
The app object is itself a function — you can pass it directly to Node's http.createServer() — and it implements the middleware interface, which is why it can be mounted inside other apps .
Settings
The app.set() method controls how Express behaves. Some settings are purely for your own use — you can store any value you want. Others are special names that configure the framework itself .
app.set('view engine', 'pug'); // Use Pug for templates
app.set('trust proxy', true); // Behind a reverse proxy
app.set('json spaces', 2); // Pretty-print JSON in development
The most important settings for production are env (which controls whether you're in development or production mode) and trust proxy (which affects how Express determines client IP addresses when behind a load balancer or reverse proxy).
Sub-Applications
One application can be mounted inside another. The parent app treats the child as just another middleware function — because that's exactly what it is.
const adminApp = express();
adminApp.get('/dashboard', (req, res) => res.send('Admin Dashboard'));
const mainApp = express();
mainApp.use('/admin', adminApp);
Requests to /admin/dashboard flow through mainApp, hit the mounted adminApp, and find the route there. This is the same mechanism as mounting a router, but with the full power of a separate application — its own settings, its own middleware stack, its own view engine .
Sub-applications inherit some settings from their parent but not all. This nuance matters when you're building modular systems.
2.3 The Router Object
A router is essentially a mini-application. The Express documentation describes it as "a 'mini-application,' capable only of performing middleware and routing functions" .
The key difference: an application has settings, a view engine, and can be mounted as a server. A router only has middleware and routes. It's a lightweight container for a group of related handlers.
Why Routers Exist
Imagine an API with 50 endpoints spread across users, products, orders, and payments. Defining all of them on the main app object would create a single massive file with no organizational structure.
Routers solve this by letting you group routes logically:
// routes/users.js
const router = express.Router();
router.get('/', (req, res) => res.send('User list'));
router.get('/:id', (req, res) => res.send(`User ${req.params.id}`));
router.post('/', (req, res) => res.status(201).send('User created'));
module.exports = router;
// app.js
const userRouter = require('./routes/users');
app.use('/users', userRouter);
Now /users and /users/:id are handled by the router, which knows nothing about the /users prefix. The prefix is applied by the parent when it mounts the router. This means you can move the router to a different prefix without changing a single line inside it.
Routers Are Middleware
This is the elegant part: "A router behaves like middleware itself, so you can use it as an argument to app.use() or as the argument to another router's use() method" .
Routers can be nested. A router can mount another router. The pipeline structure applies recursively.
const apiRouter = express.Router();
const v1Router = express.Router();
v1Router.get('/status', (req, res) => res.json({ ok: true }));
apiRouter.use('/v1', v1Router);
app.use('/api', apiRouter);
// GET /api/v1/status now works
Router-Specific Methods
Routers have their own versions of the application methods — router.use(), router.get(), router.post(), and so on. They also have one method the application object doesn't: router.param().
router.param() lets you define middleware that runs whenever a specific route parameter is present. This is a clean way to handle parameter validation or loading:
router.param('userId', (req, res, next, id) => {
req.user = findUserById(id);
if (!req.user) return next(new Error('User not found'));
next();
});
router.get('/users/:userId', (req, res) => {
res.json(req.user); // Already loaded by the param handler
});
The handler runs once per request for that parameter, before any route that uses it. It's a targeted piece of middleware triggered by the route structure itself.
2.4 The Request Object (req)
The req object is Express's enhancement of Node's native HTTP request. It carries everything about the incoming request — the URL, the method, the headers, the body, the parameters — and adds convenience properties and methods on top of what Node provides .
The Properties You'll Actually Use
req.params — Route parameters from the path. For a route defined as /users/:id, a request to /users/42 gives you req.params.id === '42'. Wildcard parameters become arrays. Optional segments that aren't present are omitted entirely .
req.query — Parsed query string parameters. For /search?q=express&limit=10, you get { q: 'express', limit: '10' }. The parser can be configured — simple mode uses Node's built-in querystring parser, extended mode uses the qs library for nested objects .
req.body — The parsed request body. Critical caveat: req.body is populated by body-parsing middleware. Without express.json() or express.urlencoded(), it's undefined. And because it's built from user-controlled input, every value in it is untrusted until you validate it .
req.method — The HTTP method as a string: 'GET', 'POST', 'PUT', etc. .
req.path — The path portion of the URL, without query string. Note that when used inside mounted middleware, the mount point is stripped .
req.hostname and req.ip — Derived from headers, with behavior affected by the trust proxy setting. Behind a proxy, these need proper configuration to be accurate .
The Methods
req.get(field) — Retrieves a request header, case-insensitively. req.get('Content-Type') returns the header value or undefined .
req.accepts(types) — Content negotiation. Checks whether the client's Accept header allows a given type. Returns the best match or false .
2.5 The Response Object (res)
The res object is Express's enhancement of Node's native response. It adds methods for sending data, setting headers, and managing the response lifecycle .
The Core Methods
res.send(body) — The workhorse. Sends a response, automatically setting the Content-Type based on what you pass. Strings become HTML by default. Objects and arrays are serialized to JSON.
res.json(obj) — Explicitly sends JSON. Sets Content-Type: application/json and serializes the object. In practice, res.send() with an object does the same thing, but res.json() is clearer about intent.
res.status(code) — Sets the HTTP status code. Chainable: res.status(404).json({ error: 'Not found' }).
res.redirect([status], path) — Sends a redirect. Defaults to 302. Supports absolute URLs, root-relative paths, and path-relative redirects .
res.render(view, locals) — Renders a template and sends the HTML. This is where templating engines plug in.
res.sendFile(path) — Sends a file from disk. Requires an absolute path (or the root option). Has options for cache control, dotfiles, and more .
Response Lifecycle
Once you call a method like res.send() or res.json(), the response is sent and the request-response cycle ends. Any middleware after that point in the chain won't run for that request.
You can set headers before sending:
res.set('X-Custom-Header', 'value');
res.status(201).json({ created: true });
Headers set after the response is sent are ignored (and may throw warnings).
Response Local Variables
res.locals is an object scoped to the current request. Anything you put there is available to templates rendered during that request . This is useful for passing request-specific data (like the authenticated user) to views without threading it through every function call.
app.use((req, res, next) => {
res.locals.user = req.session?.user || null;
next();
});