Understanding Middleware in Express.js: The Complete Guide

Introduction: Every Request Goes on a Journey
When a request arrives at your Express server, it does not teleport directly to your route handler and immediately get a response. It travels through a series of stops along the way. At each stop, something can examine the request, do some work, modify something, or decide whether the request should continue forward or be stopped right there.
These stops are middleware.
If you have written any Express code at all, you have already used middleware, probably without thinking deeply about it. That line app.use(express.json()) that you add near the top of every Express server? That is middleware. The authentication check you put before your protected routes? Also middleware. The logging you might do for every incoming request? Middleware again.
Middleware is one of those concepts that seems abstract when described in isolation but becomes immediately clear when you see what it does and where it fits. By the end of this article, you will understand not just what middleware is, but why Express is designed around it, how execution order works, what the next() function does, and how to write your own middleware for real-world purposes.
Part 1: What Middleware Actually Is
The word middleware can sound intimidating. It sounds like infrastructure, like something complicated that belongs in an enterprise architecture diagram. In Express, the reality is much simpler.
Middleware is a function. That is all it is. A function that has access to the request object, the response object, and a special function called next. It sits between the incoming request and the final route handler that sends a response.
The signature of a middleware function looks like this:
function myMiddleware(req, res, next) {
// do something with the request or response
// then either:
// call next() to pass control to the next middleware
// or send a response to end the cycle
}
Three parameters: req, res, and next. You already know req and res from route handlers. The next parameter is what makes middleware different from a route handler. Calling next() says "I am done with my work, pass control to whoever comes next in the chain."
The Checkpoint Analogy
Think about going through security at an airport. You walk up to the security area. There is a checkpoint where your boarding pass is checked. If it is valid, you move forward. If it is not, you are stopped right there and cannot proceed.
You then go through the scanner. The scanner checks whether you are carrying anything prohibited. If everything is fine, you move forward. If something is detected, you stop.
You might go through additional checks. Eventually, if you pass every checkpoint, you reach your gate and board your plane.
Each checkpoint in this process is like middleware in Express:
Incoming Request
|
v
Checkpoint 1: Is the request logged? (Logging middleware)
|
v
Checkpoint 2: Is the body parseable? (Body parsing middleware)
|
v
Checkpoint 3: Is the user authenticated? (Authentication middleware)
|
v
Checkpoint 4: Is the input valid? (Validation middleware)
|
v
Route Handler: Processes and responds (Your actual route code)
|
v
Response sent to client
At any checkpoint, the process can be stopped. The authentication check might say "this person is not logged in" and send a 401 response, preventing the request from ever reaching the route handler. The validation check might say "this data is malformed" and send a 400 response.
If all checkpoints pass, the request reaches its destination: the route handler that does the actual business logic and sends back the final response.
The Pipeline Analogy
Another way to think about it: water flowing through a series of pipes and filters. Each filter does something to the water as it passes through. Some filters remove particles. Some add minerals. Some check the pressure. The water enters one end and, after passing through all the filters, comes out the other end in a different state.
A request is the water. Middleware functions are the filters. The response that eventually comes out has been shaped and processed by everything the request passed through.
REQUEST IN → [Logger] → [Parser] → [Auth] → [Validator] → [Handler] → RESPONSE OUT
This is the request pipeline. Middleware functions are the stages of that pipeline.
Part 2: Where Middleware Sits in the Request Lifecycle
To understand where middleware fits, we need to understand the complete lifecycle of a request in Express.
When a client sends a request to your server, here is the sequence of events:
1. Client sends HTTP request
|
v
2. Node.js receives the raw request
|
v
3. Express creates req and res objects
|
v
4. Express begins working through the middleware stack
|
v
5. First registered middleware runs
|
v
6. If next() is called, second middleware runs
|
v
7. ... continues through the stack ...
|
v
8. Matching route handler runs
|
v
9. Response is sent to the client
|
v
10. Request-response cycle is complete
The middleware stack is the ordered list of all middleware functions you have registered with Express. When a request comes in, Express walks through this list from top to bottom, calling each function in turn, as long as each function calls next().
The Order Is Everything
Here is a crucial point: Express processes middleware in the order it is registered. If you register middleware A before middleware B, middleware A always runs before middleware B for every request.
This means the order of your app.use() calls in your server file is not a matter of preference. It is functional. Putting things in the wrong order causes bugs that can be subtle and frustrating to diagnose.
// WRONG ORDER: Body parsing comes after the route that needs the body
app.post('/data', function(req, res) {
console.log(req.body); // undefined! Body was not parsed yet
res.json({ received: req.body });
});
app.use(express.json()); // Too late, the route already ran
// CORRECT ORDER: Body parsing comes before routes that need it
app.use(express.json()); // Runs first for every request
app.post('/data', function(req, res) {
console.log(req.body); // Works correctly
res.json({ received: req.body });
});
The express.json() middleware reads the request body and parses it as JSON, attaching the result to req.body. If the route handler runs before this middleware, req.body is not yet populated. The middleware must come first.
Part 3: The next() Function
The next function is the mechanism that moves a request forward through the middleware chain. Understanding it clearly is essential to writing middleware correctly.
What next() Does
When you call next() inside a middleware function, you are telling Express: "I have finished my work. Please pass this request to the next middleware function or route handler in the stack."
Express then looks at the next registered function and calls it with the same req and res objects, plus a new next function that points to the one after that.
function firstMiddleware(req, res, next) {
console.log('First middleware running');
next(); // Pass to secondMiddleware
}
function secondMiddleware(req, res, next) {
console.log('Second middleware running');
next(); // Pass to thirdMiddleware
}
function thirdMiddleware(req, res, next) {
console.log('Third middleware running');
next(); // Pass to the route handler
}
app.use(firstMiddleware);
app.use(secondMiddleware);
app.use(thirdMiddleware);
app.get('/', function(req, res) {
console.log('Route handler running');
res.send('Done');
});
When a GET request arrives for /, the console output is:
First middleware running
Second middleware running
Third middleware running
Route handler running
Each function passes control to the next. The chain is followed in order.
What Happens When You Do Not Call next()
If a middleware function does not call next() and does not send a response, the request hangs. The client waits indefinitely and eventually times out. This is a bug.
function brokenMiddleware(req, res, next) {
console.log('This middleware never calls next');
// Neither next() nor res.send() or similar
// The request is now stuck forever
}
app.use(brokenMiddleware);
app.get('/', function(req, res) {
res.send('This never runs');
});
Every middleware function must either call next() to continue the chain or send a response to end the cycle. There is no third option.
Stopping the Chain Intentionally
Sometimes you want to stop the chain deliberately. Authentication middleware is a good example: if the user is not authenticated, you send a 401 response and stop. You do not call next(), so the route handler never runs.
function authenticate(req, res, next) {
const token = req.headers['authorization'];
if (!token) {
// Stop the chain, send a response
return res.status(401).json({ error: 'No token provided' });
}
// Token exists, verify it...
// If valid, call next() to continue
next();
}
The return before res.status(401) is important. Without it, the function would continue executing after sending the response and might call next() anyway, or try to send a second response. Using return ensures the function stops completely after sending the response.
Passing Errors with next()
If you pass an argument to next(), Express treats it as an error and jumps directly to your error handling middleware, skipping all other regular middleware and route handlers.
function riskyMiddleware(req, res, next) {
try {
const result = someOperationThatMightFail();
req.processedData = result;
next();
} catch (error) {
// Pass the error to Express's error handler
next(error);
}
}
We will cover error handling middleware in detail later in this article.
Part 4: Types of Middleware in Express
Express organizes middleware into several categories. Understanding these categories helps you know when and how to use each type.
Application-Level Middleware
Application-level middleware is attached directly to the app object using app.use() or app.METHOD(). It applies to requests across your entire application.
Middleware that runs for every request (no path specified):
const express = require('express');
const app = express();
// This middleware runs for EVERY request to ANY route
app.use(function(req, res, next) {
console.log('Request received:', req.method, req.url);
next();
});
app.get('/', function(req, res) {
res.send('Homepage');
});
app.get('/about', function(req, res) {
res.send('About page');
});
Every request — GET /, GET /about, POST /users, DELETE /posts/5 — goes through that logging middleware first.
Middleware that runs only for a specific path:
// This middleware only runs for requests starting with /api
app.use('/api', function(req, res, next) {
console.log('API request received at:', new Date().toISOString());
next();
});
app.get('/api/users', function(req, res) {
res.json({ users: [] });
});
app.get('/about', function(req, res) {
// This route does NOT go through the /api middleware
res.send('About page');
});
When you specify a path in app.use(), the middleware only runs when the request URL starts with that path.
Middleware for a specific method and path:
// Only runs for POST requests to /users
app.post('/users', function(req, res, next) {
// Validate the request body before creating a user
if (!req.body.name) {
return res.status(400).json({ error: 'Name is required' });
}
next();
}, function(req, res) {
// This is the actual route handler
res.status(201).json({ message: 'User created', name: req.body.name });
});
Here, two functions are passed to app.post(). Express calls them in order: the validation function first, and if it calls next(), the route handler second. This is how you can chain multiple functions directly in a route definition.
Router-Level Middleware
Express has a Router class that creates mini-applications — isolated groups of routes and middleware. Router-level middleware works exactly like application-level middleware, but it is bound to an instance of express.Router() instead of the app.
This is how Express applications are organized as they grow. Instead of all routes in one file, you create separate routers for different parts of your application:
// routes/users.js
const express = require('express');
const router = express.Router();
// Middleware that applies to all routes in this router
router.use(function(req, res, next) {
console.log('Users router middleware running');
next();
});
// Routes within this router
router.get('/', function(req, res) {
res.json({ users: [] });
});
router.get('/:id', function(req, res) {
res.json({ userId: req.params.id });
});
router.post('/', function(req, res) {
res.status(201).json({ message: 'User created' });
});
module.exports = router;
// server.js
const express = require('express');
const app = express();
const usersRouter = require('./routes/users');
app.use(express.json());
// Mount the users router at /users
// All routes in usersRouter are now under /users
app.use('/users', usersRouter);
// GET /users → users router, '/' route
// GET /users/42 → users router, '/:id' route
// POST /users → users router, '/' POST route
app.listen(3000);
Router-level middleware lets you organize both routes and middleware by domain. Authentication middleware for the admin section of your app does not need to run for the public-facing section. You put that authentication middleware on the admin router, and it only applies there.
Built-in Middleware
Express comes with several middleware functions built in. You do not need to install anything extra — they are part of Express itself.
express.json()
Parses incoming requests with JSON payloads. When a request arrives with Content-Type: application/json, this middleware reads the body, parses it as JSON, and attaches the result to req.body.
app.use(express.json());
app.post('/data', function(req, res) {
// req.body is now the parsed JSON object
console.log(req.body);
res.json({ received: req.body });
});
Without this middleware, req.body is undefined for JSON requests.
express.urlencoded()
Parses incoming requests with URL-encoded payloads — the format used by HTML forms with the default encoding type.
app.use(express.urlencoded({ extended: true }));
app.post('/form', function(req, res) {
// req.body contains the form fields
console.log(req.body.username);
console.log(req.body.email);
res.send('Form received');
});
The extended: true option allows parsing of rich objects and arrays. extended: false uses a simpler parsing library. For most applications, extended: true is the right choice.
express.static()
Serves static files from a directory. When a request comes in for a file that exists in the specified directory, Express reads and sends that file.
// Files in the 'public' directory are served at the root URL
app.use(express.static('public'));
// Files in 'uploads' are served at /uploads
app.use('/uploads', express.static('uploads'));
If you have a file at public/images/logo.png, it becomes accessible at http://yourserver.com/images/logo.png. This is how you serve CSS, JavaScript, images, and other static assets.
express.Router()
Technically express.Router() creates a router object, but it functions as middleware when mounted with app.use(). It was covered in the router-level section above.
Third-Party Middleware
Beyond built-in middleware, there is a huge ecosystem of third-party middleware packages that solve common problems. You install them with npm and use them like any other middleware.
Some commonly used examples:
morgan — HTTP request logger
const morgan = require('morgan');
// 'dev' format: colored output, shows method, url, status, response time
app.use(morgan('dev'));
// Output looks like: GET /users 200 5.234 ms - 128
cors — Cross-Origin Resource Sharing headers
const cors = require('cors');
// Allow requests from any origin
app.use(cors());
// Or with specific configuration
app.use(cors({
origin: 'https://yourfrontend.com',
methods: ['GET', 'POST', 'PUT', 'DELETE']
}));
helmet — Sets various HTTP headers for security
const helmet = require('helmet');
// Adds many security-related HTTP headers automatically
app.use(helmet());
express-rate-limit — Rate limiting
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // Max 100 requests per window
});
app.use(limiter);
All of these follow the same pattern: import the package, call it (often with configuration options), and pass the result to app.use().
Part 5: Writing Your Own Middleware
Understanding how to write custom middleware is where things become genuinely useful. Let us walk through building middleware for three real-world needs: logging, authentication, and request validation.
Middleware Pattern: The Basic Structure
Every middleware function follows the same structure:
function middlewareName(req, res, next) {
// 1. Do your work here
// - Read from req
// - Modify req or res
// - Perform async operations
// 2. Either send a response:
// return res.status(400).json({ error: 'Something wrong' });
// 3. Or call next() to continue:
next();
}
// Register it
app.use(middlewareName);
For async operations, you need to handle errors carefully:
async function asyncMiddleware(req, res, next) {
try {
const result = await someAsyncOperation();
req.asyncData = result;
next();
} catch (error) {
next(error); // Pass errors to Express error handler
}
}
Real-World Example 1: Request Logger
A logger records information about every request: when it arrived, what method, what URL, and eventually what response was sent and how long it took.
// middleware/logger.js
function requestLogger(req, res, next) {
// Record when the request arrived
const startTime = Date.now();
const timestamp = new Date().toISOString();
// Log the incoming request
console.log(`[\({timestamp}] --> \){req.method} ${req.url}`);
// We want to log the response status and time too,
// but the response has not been sent yet when this middleware runs.
// We can hook into the 'finish' event on the response object,
// which fires when Express finishes sending the response.
res.on('finish', function() {
const duration = Date.now() - startTime;
const statusCode = res.statusCode;
// Color code the status for readability in terminal
let statusDisplay;
if (statusCode >= 200 && statusCode < 300) {
statusDisplay = statusCode; // Success
} else if (statusCode >= 400 && statusCode < 500) {
statusDisplay = statusCode; // Client error
} else if (statusCode >= 500) {
statusDisplay = statusCode; // Server error
} else {
statusDisplay = statusCode;
}
console.log(
`[\({new Date().toISOString()}] <-- \){req.method} ${req.url} ` +
`\({statusDisplay} \){duration}ms`
);
});
// Continue to the next middleware
next();
}
module.exports = requestLogger;
// server.js
const requestLogger = require('./middleware/logger');
// Register early so it captures all requests
app.use(requestLogger);
// Routes come after
app.get('/', function(req, res) {
res.send('Hello World');
});
Output when requests arrive:
[2024-01-15T10:30:00.000Z] --> GET /
[2024-01-15T10:30:00.005Z] <-- GET / 200 5ms
[2024-01-15T10:30:01.000Z] --> POST /users
[2024-01-15T10:30:01.023Z] <-- POST /users 201 23ms
[2024-01-15T10:30:02.000Z] --> GET /nonexistent
[2024-01-15T10:30:02.002Z] <-- GET /nonexistent 404 2ms
The res.on('finish') pattern is useful: it lets middleware set up work that happens after the response is sent, without blocking the request from continuing.
Real-World Example 2: Authentication Middleware
Authentication middleware checks whether the request includes valid credentials before allowing it to reach protected route handlers.
// middleware/authenticate.js
const jwt = require('jsonwebtoken');
function authenticate(req, res, next) {
// Step 1: Get the authorization header
const authHeader = req.headers['authorization'];
// Step 2: Check it exists and has the right format
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Authentication required',
message: 'Please include a Bearer token in the Authorization header'
});
}
// Step 3: Extract the token
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
// Step 4: Verify the token
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Step 5: Attach user info to the request for use downstream
req.user = decoded;
// Step 6: Continue to the next middleware or route handler
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Token expired',
message: 'Your session has expired. Please log in again.'
});
}
return res.status(401).json({
error: 'Invalid token',
message: 'The provided token is not valid.'
});
}
}
module.exports = authenticate;
// Usage: Apply to specific routes
const authenticate = require('./middleware/authenticate');
// Public routes - no authentication needed
app.get('/public', function(req, res) {
res.json({ message: 'Anyone can see this' });
});
app.post('/auth/login', function(req, res) {
// Login route is public
res.json({ token: 'generated token here' });
});
// Protected routes - authentication required
app.get('/profile', authenticate, function(req, res) {
// req.user is available because authenticate added it
res.json({
message: 'Your profile',
user: req.user
});
});
app.put('/settings', authenticate, function(req, res) {
res.json({
message: 'Settings updated',
userId: req.user.userId
});
});
Or apply to an entire router:
const protectedRouter = require('./routes/protected');
const authenticate = require('./middleware/authenticate');
// Everything in protectedRouter requires authentication
app.use('/api', authenticate, protectedRouter);
Real-World Example 3: Request Validation Middleware
Validation middleware checks that incoming request data meets your requirements before the request reaches the business logic of your route handler.
// middleware/validate.js
// A factory function that creates validation middleware
// It takes a validation schema and returns a middleware function
function validate(schema) {
return function(req, res, next) {
const errors = [];
// Check each field defined in the schema
for (const fieldName in schema) {
const rules = schema[fieldName];
const value = req.body[fieldName];
// Check required fields
if (rules.required && (value === undefined || value === null || value === '')) {
errors.push({
field: fieldName,
message: fieldName + ' is required'
});
continue; // No point checking other rules if field is missing
}
// Skip other checks if value is not provided and not required
if (value === undefined || value === null) {
continue;
}
// Check type
if (rules.type === 'string' && typeof value !== 'string') {
errors.push({
field: fieldName,
message: fieldName + ' must be a string'
});
}
if (rules.type === 'number' && typeof value !== 'number') {
errors.push({
field: fieldName,
message: fieldName + ' must be a number'
});
}
// Check minimum length for strings
if (rules.minLength && typeof value === 'string' && value.length < rules.minLength) {
errors.push({
field: fieldName,
message: fieldName + ' must be at least ' + rules.minLength + ' characters long'
});
}
// Check maximum length for strings
if (rules.maxLength && typeof value === 'string' && value.length > rules.maxLength) {
errors.push({
field: fieldName,
message: fieldName + ' cannot exceed ' + rules.maxLength + ' characters'
});
}
// Check minimum value for numbers
if (rules.min !== undefined && typeof value === 'number' && value < rules.min) {
errors.push({
field: fieldName,
message: fieldName + ' must be at least ' + rules.min
});
}
// Check email format
if (rules.email && typeof value === 'string') {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(value)) {
errors.push({
field: fieldName,
message: fieldName + ' must be a valid email address'
});
}
}
}
// If there are errors, stop here and return them
if (errors.length > 0) {
return res.status(400).json({
error: 'Validation failed',
details: errors
});
}
// All validation passed, continue
next();
};
}
module.exports = validate;
// Usage in routes
const validate = require('./middleware/validate');
const authenticate = require('./middleware/authenticate');
// Define validation schemas for different routes
const registerSchema = {
name: {
required: true,
type: 'string',
minLength: 2,
maxLength: 50
},
email: {
required: true,
type: 'string',
email: true
},
password: {
required: true,
type: 'string',
minLength: 8
}
};
const updateProfileSchema = {
name: {
required: false,
type: 'string',
minLength: 2,
maxLength: 50
},
age: {
required: false,
type: 'number',
min: 0
}
};
const createPostSchema = {
title: {
required: true,
type: 'string',
minLength: 5,
maxLength: 200
},
content: {
required: true,
type: 'string',
minLength: 10
}
};
// Apply validation as middleware before route handlers
app.post('/auth/register', validate(registerSchema), function(req, res) {
// Validation passed, req.body is clean and valid
const { name, email, password } = req.body;
res.status(201).json({ message: 'User registered', name, email });
});
app.put('/profile', authenticate, validate(updateProfileSchema), function(req, res) {
// Both authenticated AND validated
res.json({ message: 'Profile updated' });
});
app.post('/posts', authenticate, validate(createPostSchema), function(req, res) {
// Authenticated user creating a valid post
res.status(201).json({
message: 'Post created',
title: req.body.title,
author: req.user.userId
});
});
When validation fails, the response looks like:
{
"error": "Validation failed",
"details": [
{
"field": "email",
"message": "email must be a valid email address"
},
{
"field": "password",
"message": "password must be at least 8 characters long"
}
]
}
The route handler never runs. It only runs when all validation passes.
Part 6: Middleware Execution Order — Seeing It Clearly
Execution order is so important that it deserves its own section with a detailed, step-by-step walkthrough.
A Complete Annotated Example
const express = require('express');
const app = express();
// MIDDLEWARE 1: Runs for every request, first
app.use(function stepOne(req, res, next) {
console.log('Step 1: Request arrived -', req.method, req.url);
req.customData = { step: 1 };
next();
});
// MIDDLEWARE 2: Body parsing, runs for every request, second
app.use(express.json());
// MIDDLEWARE 3: Runs for every request, third
app.use(function stepThree(req, res, next) {
console.log('Step 3: After body parsing');
req.customData.step = 3;
next();
});
// MIDDLEWARE 4: Only runs for /api routes
app.use('/api', function apiMiddleware(req, res, next) {
console.log('Step 4: Inside /api middleware');
req.isApiRequest = true;
next();
});
// ROUTE HANDLER: Only runs for GET /api/data
app.get('/api/data', function routeHandler(req, res) {
console.log('Route handler: Processing GET /api/data');
res.json({
message: 'Data response',
customData: req.customData,
isApiRequest: req.isApiRequest
});
});
// ROUTE HANDLER: Only runs for GET /home
app.get('/home', function homeHandler(req, res) {
console.log('Route handler: Processing GET /home');
res.json({
message: 'Home page',
customData: req.customData,
isApiRequest: req.isApiRequest // undefined, /api middleware did not run
});
});
When a request arrives for GET /api/data:
Request: GET /api/data
|
v
stepOne runs → logs "Step 1: Request arrived - GET /api/data"
→ sets req.customData = { step: 1 }
→ calls next()
|
v
express.json() runs → reads and parses request body
→ sets req.body
→ calls next()
|
v
stepThree runs → logs "Step 3: After body parsing"
→ updates req.customData.step = 3
→ calls next()
|
v
apiMiddleware runs → URL starts with /api, so this runs
→ logs "Step 4: Inside /api middleware"
→ sets req.isApiRequest = true
→ calls next()
|
v
routeHandler runs → matches GET /api/data
→ logs "Route handler: Processing GET /api/data"
→ sends response
Console output:
Step 1: Request arrived - GET /api/data
Step 3: After body parsing
Step 4: Inside /api middleware
Route handler: Processing GET /api/data
When a request arrives for GET /home:
Request: GET /home
|
v
stepOne runs → runs (no path filter)
|
v
express.json() runs → runs (no path filter)
|
v
stepThree runs → runs (no path filter)
|
v
apiMiddleware → SKIPPED: URL does not start with /api
|
v
homeHandler runs → matches GET /home, sends response
Console output:
Step 1: Request arrived - GET /home
Step 3: After body parsing
Route handler: Processing GET /home
The /api middleware is skipped entirely for the /home route.
Middleware That Stops the Chain
Here is what happens when middleware stops the chain:
app.use(function alwaysRuns(req, res, next) {
console.log('1: Always runs');
next();
});
app.use(function gatekeeper(req, res, next) {
console.log('2: Gatekeeper checking...');
const hasAccess = req.headers['x-api-key'] === 'secret123';
if (!hasAccess) {
console.log('2: Access denied, stopping chain');
return res.status(403).json({ error: 'Access denied' });
// next() is NOT called, chain stops here
}
console.log('2: Access granted, continuing');
next();
});
app.use(function afterGatekeeper(req, res, next) {
console.log('3: Only runs if gatekeeper approved');
next();
});
app.get('/data', function(req, res) {
console.log('4: Route handler');
res.json({ data: 'secret data' });
});
Request without API key:
1: Always runs
2: Gatekeeper checking...
2: Access denied, stopping chain
[Response sent: 403 Access denied]
[Middleware 3 and route handler never run]
Request with correct API key:
1: Always runs
2: Gatekeeper checking...
2: Access granted, continuing
3: Only runs if gatekeeper approved
4: Route handler
[Response sent: 200 with data]
The Execution Order Rule
Always arrange middleware in this general order:
// 1. Security headers (first, before anything else)
app.use(helmet());
// 2. CORS configuration
app.use(cors());
// 3. Logging (early, to capture all requests)
app.use(requestLogger);
// 4. Rate limiting
app.use(rateLimiter);
// 5. Body parsing (before routes that need req.body)
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 6. Static files
app.use(express.static('public'));
// 7. Routes (after all global middleware)
app.use('/auth', authRoutes);
app.use('/api', authenticate, apiRoutes);
// 8. 404 handler (after all routes)
app.use(function(req, res) {
res.status(404).json({ error: 'Route not found' });
});
// 9. Error handler (always last)
app.use(function(err, req, res, next) {
res.status(500).json({ error: err.message });
});
Part 7: Error Handling Middleware
Error handling middleware is a special type of middleware that takes four parameters instead of three. Express recognizes the four-parameter signature and treats the function as an error handler.
// The four parameters are what makes this an error handler
function errorHandler(err, req, res, next) {
console.error('Error occurred:', err.message);
console.error('Stack trace:', err.stack);
res.status(err.statusCode || 500).json({
error: err.message || 'Internal server error'
});
}
// Error handlers are registered with app.use() just like other middleware
// but they must come AFTER all routes and regular middleware
app.use(errorHandler);
Creating Custom Error Classes
Custom error classes let you create errors with additional information:
// utils/AppError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.name = 'AppError';
}
}
module.exports = AppError;
// In middleware or route handlers
const AppError = require('./utils/AppError');
function authenticate(req, res, next) {
const token = req.headers['authorization'];
if (!token) {
// Create an error and pass it to the error handler
return next(new AppError('Authentication required', 401));
}
next();
}
// In route handlers
app.get('/admin', authenticate, function(req, res, next) {
if (req.user.role !== 'admin') {
return next(new AppError('Admin access required', 403));
}
res.json({ data: 'admin data' });
});
// The error handler at the bottom of server.js
app.use(function(err, req, res, next) {
// Log the error for debugging
console.error('Error:', err.message);
if (process.env.NODE_ENV !== 'production') {
console.error('Stack:', err.stack);
}
// Determine the status code
const statusCode = err.statusCode || 500;
// Build the response
const response = {
error: err.message || 'Something went wrong'
};
// In development, include the stack trace
if (process.env.NODE_ENV === 'development') {
response.stack = err.stack;
}
res.status(statusCode).json(response);
});
The 404 Handler
The 404 handler is a regular middleware function (not an error handler) that catches any request that did not match any route:
// This goes after all routes but before the error handler
app.use(function notFound(req, res, next) {
res.status(404).json({
error: 'Route not found',
method: req.method,
path: req.url,
message: 'The requested endpoint does not exist'
});
});
Because Express processes middleware in order, this only runs if no earlier route handler sent a response. Every defined route gets a chance first. If none of them match, this catches the request and sends a 404.
Part 8: Middleware That Modifies the Request
One of the most common patterns in middleware is adding data to the req object so that subsequent middleware and route handlers have access to it.
Attaching Data to Requests
// middleware/attachRequestId.js
const crypto = require('crypto');
function attachRequestId(req, res, next) {
// Generate a unique ID for this request
req.requestId = crypto.randomUUID();
// Add it to the response headers so clients can reference it
res.setHeader('X-Request-Id', req.requestId);
next();
}
module.exports = attachRequestId;
// middleware/attachTimestamp.js
function attachTimestamp(req, res, next) {
req.receivedAt = new Date();
next();
}
module.exports = attachTimestamp;
// middleware/loadUser.js
const users = require('../data/users');
const jwt = require('jsonwebtoken');
// This middleware tries to load the user but does not require authentication
// Routes can check req.user and respond accordingly
function loadUser(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
req.user = null;
return next();
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
} catch (error) {
req.user = null;
}
next();
}
module.exports = loadUser;
// Using these middleware together
app.use(attachRequestId);
app.use(attachTimestamp);
app.use(loadUser);
app.get('/content', function(req, res) {
// req.requestId, req.receivedAt, and req.user are all available
const responseData = {
requestId: req.requestId,
processedAt: new Date().toISOString(),
processingTime: new Date() - req.receivedAt + 'ms'
};
if (req.user) {
// Show personalized content for logged-in users
responseData.content = 'Personalized content for ' + req.user.name;
} else {
// Show generic content for anonymous users
responseData.content = 'Public content';
}
res.json(responseData);
});
Part 9: A Complete Application Showing Middleware in Context
Let us put everything together in a single, coherent application that demonstrates all the middleware concepts we have covered:
// server.js
require('dotenv').config();
const express = require('express');
const app = express();
// ============================================================
// GLOBAL MIDDLEWARE
// These run for every request, in this exact order
// ============================================================
// 1. Request ID and timestamp (very first, captures everything)
app.use(function(req, res, next) {
req.requestId = Math.random().toString(36).substring(2, 9);
req.startTime = Date.now();
res.setHeader('X-Request-Id', req.requestId);
next();
});
// 2. Request logger
app.use(function(req, res, next) {
console.log('[' + req.requestId + '] ' + req.method + ' ' + req.url);
res.on('finish', function() {
const duration = Date.now() - req.startTime;
console.log('[' + req.requestId + '] ' + res.statusCode + ' in ' + duration + 'ms');
});
next();
});
// 3. Body parsers
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 4. Static files
app.use(express.static('public'));
// ============================================================
// AUTHENTICATION MIDDLEWARE (used selectively)
// ============================================================
const jwt = require('jsonwebtoken');
function authenticate(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
req.user = jwt.verify(authHeader.substring(7), process.env.JWT_SECRET);
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// ============================================================
// VALIDATION MIDDLEWARE (used selectively)
// ============================================================
function validateBody(requiredFields) {
return function(req, res, next) {
const missing = requiredFields.filter(function(field) {
return !req.body[field] || req.body[field].toString().trim() === '';
});
if (missing.length > 0) {
return res.status(400).json({
error: 'Missing required fields',
fields: missing
});
}
next();
};
}
// ============================================================
// PUBLIC ROUTES
// ============================================================
app.get('/health', function(req, res) {
res.json({ status: 'healthy', requestId: req.requestId });
});
app.post('/auth/login',
validateBody(['email', 'password']),
function(req, res) {
// In a real app, verify credentials against database
const token = jwt.sign(
{ userId: 1, email: req.body.email, name: 'Alice' },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({
success: true,
token: token
});
}
);
// ============================================================
// PROTECTED API ROUTES
// ============================================================
const apiRouter = express.Router();
// All routes in apiRouter require authentication
apiRouter.use(authenticate);
apiRouter.get('/profile', function(req, res) {
res.json({
success: true,
user: req.user,
requestId: req.requestId
});
});
apiRouter.put('/profile',
validateBody(['name']),
function(req, res) {
res.json({
success: true,
message: 'Profile updated',
updatedBy: req.user.userId
});
}
);
apiRouter.post('/posts',
validateBody(['title', 'content']),
function(req, res) {
res.status(201).json({
success: true,
post: {
title: req.body.title,
content: req.body.content,
author: req.user.userId,
createdAt: new Date().toISOString()
}
});
}
);
// Mount the API router
app.use('/api', apiRouter);
// ============================================================
// 404 HANDLER (after all routes)
// ============================================================
app.use(function(req, res) {
res.status(404).json({
error: 'Not found',
path: req.url,
requestId: req.requestId
});
});
// ============================================================
// ERROR HANDLER (always last)
// ============================================================
app.use(function(err, req, res, next) {
console.error('[' + req.requestId + '] Error:', err.message);
res.status(err.statusCode || 500).json({
error: err.message || 'Internal server error',
requestId: req.requestId
});
});
// ============================================================
// START SERVER
// ============================================================
const PORT = process.env.PORT || 3000;
app.listen(PORT, function() {
console.log('Server running on port ' + PORT);
console.log('Middleware stack:');
console.log(' 1. Request ID and timestamp');
console.log(' 2. Request logger');
console.log(' 3. Body parsers');
console.log(' 4. Static files');
console.log(' 5. Routes (with selective auth and validation)');
console.log(' 6. 404 handler');
console.log(' 7. Error handler');
});
This application demonstrates:
Global middleware applied to all requests in a specific order
Reusable middleware functions defined once and applied selectively
A factory middleware pattern (
validateBody) that accepts configurationA router with middleware applied to all its routes
A proper 404 handler and error handler at the end of the chain
Request data (
requestId,startTime) added by early middleware and used throughout
Summary
Middleware in Express is a function with access to req, res, and next that sits between an incoming request and the route handler that eventually sends a response. It forms a pipeline that every request passes through.
The next() function passes control to the next middleware in the stack. Not calling next() and not sending a response leaves the request permanently stuck. Calling next(error) skips to the error handling middleware.
Middleware is registered in order with app.use() or router.use(), and Express executes it in that exact order. The order of registration is not a preference — it determines how your application works.
Application-level middleware applies across the entire app when registered with app.use(). Router-level middleware is attached to specific routers and only runs for routes in those routers. Built-in middleware like express.json(), express.urlencoded(), and express.static() handles common tasks without additional packages.
Middleware can end the request cycle by sending a response — authentication checks and validation checks do this when they detect problems. It can also modify the request object to add data for downstream middleware and route handlers.
Error handling middleware is recognized by Express through its four-parameter signature and must be placed after all routes and regular middleware. The 404 handler is a regular middleware placed after all routes to catch unmatched requests.
The practical power of middleware comes from composability: small, focused functions that each do one thing well, chained together to create sophisticated request processing pipelines that are easy to reason about, test, and modify.






