Key Takeaways
- Express middleware runs in registration order. Register shared middleware before the routes that depend on it.
- A middleware function should respond, call
next(), or forward an error withnext(err). Doing none leaves the request waiting. - Express 5 forwards rejected handler promises to error middleware; Express 4 requires explicit rejection handling.
- Put an ordinary, all-methods 404 fallback after application routes, followed by four-argument error-handling middleware.
Express middleware is a function that participates in the HTTP request-response pipeline. It can inspect or change the request and response, send a response, or pass control to another function. Registration order determines which functions a request reaches.
This tutorial targets Express 5 and calls out the important Express 4 differences. If creating an application is new to you, start with our Node.js and Express getting-started tutorial.
A Basic Express Route
A route handler participates in the middleware system. This familiar example responds to requests for the home page:
// Fragment: app is the Express application created below.
app.get('/', (req, res) => {
res.send('Hello world');
});The first argument is the route path; the second is its handler. res.send(), res.json(), and res.render() are response methods—not middleware themselves. A registered handler can call them to produce a response.
What Express Middleware Does
Ordinary middleware receives (req, res, next): the incoming request, the outgoing response, and a function that passes control onward. You don't have to declare next when your handler won't use it.
For the normal middleware pipeline, choose one outcome:
- Respond: send or end the response, then stop executing that handler.
- Continue: call
next()so Express can run the next matching middleware or route. - Forward an error: call
next(err)to skip ordinary handlers and enter error handling.
If you neither respond nor pass control onward, the request hangs. Sending a response doesn't stop JavaScript execution, so use return when you need to prevent a branch from falling through. The Express middleware-writing guide explains this contract.
Request
|
v
Logger -> security headers -> parser -> matching route -> response
|
no route responds
v
404 fallback
next(err) or a rejected Express 5 handler promise
|
v
Error middleware -> error responseOrder matters: middleware registered after a route that ends the response won't run for that request. Register logging, parsers, and access checks before the handlers that need them.
Using Existing Express Middleware
You don't need to write every middleware function yourself. Express provides built-in middleware, and third-party packages provide additional behavior.
- express.json() parses matching JSON request bodies and populates
req.body. - Helmet supplies middleware that sets security-related HTTP response headers. It doesn't configure your TLS connection.
Before adopting a package, check its official documentation, release history, security policy, and compatibility with your Express major and Node runtime. Review what it changes and whether it responds or calls next(); don't select it solely because an old tutorial recommends it.
For the runnable example, create a project with Node.js and npm installed:
mkdir express-middleware-demo
cd express-middleware-demo
npm init -y
npm install express@5 helmet
printf 'Hello\n' > greeting.txtThe examples use an app.mjs file, whose extension tells Node to interpret it as an ECMAScript module. That lets you use import without changing the package's module setting.
Mount Paths and HTTP Methods
Mounting middleware means registering where it should run. These registrations have different scopes:
| Registration | Requests it handles |
|---|---|
app.use(middleware) | All paths and HTTP methods that reach this point in the ordinary stack. |
app.use('/api', middleware) | All methods under /api, including /api itself and descendants such as /api/greetings. |
app.get('/api/greetings', handler) | GET requests matching that route. Express also uses GET handlers for HEAD when no earlier HEAD handler handles that path. |
A mount at /api doesn't match /apiculture; the path boundary matters. These rules are documented in the Express application API.
Writing Your Own Middleware
Here is the basic req, res, and next shape, with response-status logging:
// Fragment: register this before routes you want to observe.
app.use((req, res, next) => {
res.on('finish', () => {
console.log(req.method, res.statusCode);
});
next();
});Reading res.statusCode immediately on entry usually gives you its default value, 200, before a downstream handler selects another status. Registering a finish listener lets you record the status after the response has been sent.
Putting Middleware in Order: A Runnable Express 5 App
Save the following as app.mjs. It combines existing middleware, custom validation, asynchronous file reading, routes, a 404 fallback, and error handling. The small local file makes asynchronous success and failure easy to reproduce.
import express from 'express';
import helmet from 'helmet';
import { readFile } from 'node:fs/promises';
const app = express();
app.use((req, res, next) => {
res.on('finish', () => {
console.log(req.method, res.statusCode);
});
next();
});
app.use(helmet());
app.use('/api', express.json({ limit: 16 * 1024 }));
function validateName(req, res, next) {
const name = req.body?.name;
if (typeof name !== 'string' || name.trim() === '') {
return res.status(400).json({ error: 'A non-empty name is required' });
}
res.locals.name = name.trim();
next();
}
async function loadGreeting(req, res, next) {
res.locals.greeting = await readFile(
new URL('./greeting.txt', import.meta.url),
'utf8'
);
next();
}
app.get('/', (req, res) => {
res.send('Hello world');
});
app.post('/api/greetings', validateName, loadGreeting, (req, res) => {
res.json({
message: `${res.locals.greeting.trim()}, ${res.locals.name}!`
});
});
app.use((req, res) => {
res.status(404).send('Page not found');
});
app.use((err, req, res, next) => {
if (res.headersSent) {
return next(err);
}
const candidate = err?.status ?? err?.statusCode;
const status = Number.isInteger(candidate)
&& candidate >= 400 && candidate <= 599 ? candidate : 500;
console.error('Request failed', {
status,
error: err instanceof Error ? err.name : 'UnknownError'
});
res.status(status).json({
error: status >= 500 ? 'Something broke!' : 'Invalid request'
});
});
app.listen(3000, '127.0.0.1', (err) => {
if (err) {
console.error('Unable to start server', err);
process.exitCode = 1;
return;
}
console.log('Listening on port 3000');
});The JSON parser must precede validateName because that function consumes req.body. Parsing isn't validation: request-body properties remain untrusted. The demo limits parsed JSON bodies to 16,384 bytes and rejects a missing or blank name with status 400.
loadGreeting awaits Node's promise-based readFile(), then continues. Its value is stored in res.locals, which is scoped to one request-response cycle. If reading fails, Express 5 forwards the rejected promise to the error handler.
For a protected endpoint, insert your authentication middleware before the business handler. Header-based authentication can run before body parsing; anything that reads req.body must run after its parser.
Run the app, then use a second terminal to exercise it:
node app.mjscurl -i http://127.0.0.1:3000/
curl -i -H 'Content-Type: application/json' \
-d '{"name":"Ada"}' http://127.0.0.1:3000/api/greetings
curl -i -X POST http://127.0.0.1:3000/missingThese requests produce statuses 200, 200, and 404 respectively. Also test an empty name, malformed JSON, and a temporarily renamed greeting.txt; they should exercise validation, parser errors, and asynchronous errors.
Reading an unchanged greeting file on every request is deliberate here. In a real service, consider loading immutable configuration at startup instead; our Node.js performance tips cover broader request-path improvements.
Creating a Default 404 Page
A missing route isn't automatically an Express error. The final ordinary app.use handler responds when earlier routes haven't responded or forwarded an error, regardless of HTTP method. Keep it after your application routes and before the error handler.
The old app.get('*', ...) example is wrong for Express 5: unnamed wildcards are invalid, and GET registration isn't an all-methods fallback. If you actually need a wildcard route, /*splat excludes the root path, while /{*splat} includes it. A pathless app.use is simpler for this 404 handler. See the Express 5 migration guide.
Rendering the 404 Page with EJS
To render a template instead of sending text, install EJS and create a views directory:
npm install ejs
mkdir viewsCreate views/404.ejs:
<h2>Page not found</h2>
<p>The requested page doesn't exist.</p>Add the view configuration after creating app, and replace—not supplement—the existing 404 fallback:
// Fragment: configure before registering routes.
// Run node app.mjs from the project directory.
app.set('views', './views');
app.set('view engine', 'ejs');
// Fragment: replace the fallback after application routes.
app.use((req, res) => {
res.status(404).render('404');
});res.render('404') needs both a configured view engine and the corresponding template. Setting status 404 is separate from rendering the page. The EJS documentation covers its Express integration.
Express Error-Handling Middleware
Error middleware has four parameters: (err, req, res, next). Keep all four declared so Express recognizes it, and register it after the middleware and routes whose errors it handles.
The complete example preserves integer err.status or err.statusCode values in the 400–599 range instead of turning every failure into 500. Its client-facing messages are deliberately generic; add useful, redacted diagnostic context to your server-side logs.
If res.headersSent is true, you can't send a replacement error response. Delegate with next(err); Express's default error handler can close a connection when a response has already started. See the official error-handling guide and our Express error-handling patterns.
Async Errors in Express 4 Versus Express 5
Express 5 observes promises returned by middleware and route handlers and forwards rejections. A fulfilled promise does not automatically advance the pipeline: after successful work, respond or call next(). Detached promises and callback-based operations still need explicit error handling.
In Express 4, replace the asynchronous middleware with explicit rejection forwarding:
// Fragment: Express 4 replacement using the same readFile import.
async function loadGreeting(req, res, next) {
try {
res.locals.greeting = await readFile(
new URL('./greeting.txt', import.meta.url),
'utf8'
);
} catch (err) {
return next(err);
}
next();
}Express 4 doesn't automatically forward rejected handler promises; catching and calling next(err), or using a compatible wrapper, makes the failure reach error middleware.
HTTPS Middleware Behind a Proxy
Prefer HTTP-to-HTTPS redirects at your reverse proxy or load balancer. With TLS termination, that proxy handles the encrypted client connection and may forward HTTP to Node. That means an application redirect based only on the app-side connection can loop.
Express's trust proxy setting defaults to false. When appropriately configured, it lets Express use proxy-provided information, including X-Forwarded-Proto for req.protocol. Match the setting to your actual trusted proxy addresses or subnets, and require those proxies to remove or overwrite client-supplied forwarded headers.
Follow the Express proxy configuration guide, then review our Express application security checklist for the wider deployment picture.
Common Mistakes
- Responding and then calling next(): use an early return after a terminal response instead of allowing another handler to respond.
- Putting the fallback first: an early 404 response prevents later routes from handling the request.
- Starting untracked asynchronous work: await or return request-related promises; don't assume Express 5 observes background tasks.
- Testing only successful GET requests: include invalid bodies, asynchronous failures, and unmatched requests using other methods.
FAQ
What is middleware in Express?
Express middleware is a function registered in the request-response pipeline. It can inspect or modify the request and response, send a response, or pass control to another handler.
Why does the order of Express middleware matter?
Express runs matching middleware in registration order. A handler that sends a response without continuing prevents later handlers from running, so parsers and access checks must precede the routes that depend on them.
When should Express middleware call next()?
Call next() after successful work when another handler should continue processing the request. Don't call it after sending a terminal response; call next(err) when a failure should enter error handling.
How do you handle errors in async Express middleware?
Express 5 automatically forwards rejections from promises returned by middleware and route handlers. Express 4 requires explicit rejection handling, such as try/catch followed by next(err), or a compatible wrapper.
How do you create a 404 handler for all HTTP methods?
Register a pathless app.use handler after your application routes and send a response with status 404. It handles unmatched requests that reach it across HTTP methods without needing a wildcard route.
Further Reading
- Writing Express middleware — the middleware contract and registration order.
- Express built-in middleware API — JSON parsing, limits, and request-body caveats.
- Helmet documentation — security headers and configuration.
- Node.js HTTP documentation — response status and lifecycle events.
- Express error handling — promises, callbacks, and partially sent responses.
- Migrating to Express 5 — routing syntax and behavior changes.
- EJS documentation — template rendering with Express.
- Express behind proxies — trust boundaries and forwarded headers.
