Middleware
Understand the pattern that powers Express - how requests flow through your application and how to add features like authentication, logging, and error handling.
The Assembly Line#
Imagine a factory assembly line. A product starts at one end and passes through multiple stations. Each station does one thing: one adds a wheel, another paints, another inspects. The product moves through each station until it's complete.
That's exactly how Express works.
When a request comes in, it doesn't go directly to your route handler. It passes through a series of functions called middleware. Each middleware can inspect the request, modify it, add data to it, or even stop it entirely.
Request comes in
↓
[Middleware 1] → adds logging
↓
[Middleware 2] → parses JSON body
↓
[Middleware 3] → checks authentication
↓
[Route Handler] → does the actual work
↓
Response goes out
This is the most important concept in Express. Once you understand it, everything else makes sense.
Your First Middleware#
A middleware is just a function with three parameters: req, res, and next.
function myMiddleware(req, res, next) {
// Do something here
console.log('A request came in!');
// Then call next() to continue to the next middleware
next();
}
That's it. The next() function is the key - it tells Express to move to the next middleware in the chain. If you forget to call next(), the request hangs forever.
Let's use it:
import express from 'express';
const app = express();
// This runs on EVERY request
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
next();
});
app.get('/', (req, res) => {
res.send('Hello World');
});
app.listen(3000);
Now every request gets logged. Visit http://localhost:3000 and check your terminal.
Why Middleware Matters#
Without middleware, you'd have to repeat code in every route:
// WITHOUT middleware - repetitive and messy
app.get('/users', (req, res) => {
console.log('Request received'); // Logging
if (!req.headers.authorization) { // Auth check
return res.status(401).json({ error: 'Unauthorized' });
}
// ... actual logic
});
app.get('/posts', (req, res) => {
console.log('Request received'); // Same logging
if (!req.headers.authorization) { // Same auth check
return res.status(401).json({ error: 'Unauthorized' });
}
// ... actual logic
});
// Repeat for every route... 😫
With middleware, you write it once:
// WITH middleware - clean and organized
app.use(logger);
app.use('/api', authenticate);
app.get('/api/users', (req, res) => {
// Just the logic - logging and auth handled automatically
});
app.get('/api/posts', (req, res) => {
// Same here - no repeated code
});
The express.json() Mystery Solved#
Remember how you need app.use(express.json()) to read request bodies? Now you know why - it's middleware!
// This is middleware that:
// 1. Checks if the request has a JSON body
// 2. Parses it
// 3. Attaches it to req.body
// 4. Calls next()
app.use(express.json());
app.post('/users', (req, res) => {
// req.body now contains the parsed JSON
console.log(req.body); // { name: 'Sarah', email: 'sarah@example.com' }
});
Without express.json(), req.body would be undefined because no one parsed it.
Building Custom Middleware#
Let's build some useful middleware from scratch.
Request Timer#
How long do requests take? Let's find out:
function requestTimer(req, res, next) {
const start = Date.now();
// This runs AFTER the response is sent
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.path} - ${duration}ms`);
});
next();
}
app.use(requestTimer);
Now you'll see how long each request takes:
GET /api/users - 45ms
POST /api/users - 123ms
Request ID#
Tracking requests through logs is much easier with unique IDs:
import { randomUUID } from 'crypto';
function addRequestId(req, res, next) {
// Generate a unique ID
req.id = randomUUID();
// Add it to the response headers too
res.set('X-Request-Id', req.id);
next();
}
app.use(addRequestId);
app.get('/api/users', (req, res) => {
console.log(`[${req.id}] Fetching users...`);
// Now you can trace this request through your logs
res.json({ users: [] });
});
Simple Rate Limiter#
Don't let users hammer your API:
const requestCounts = new Map();
function rateLimiter(maxRequests, windowMs) {
return (req, res, next) => {
const ip = req.ip;
const now = Date.now();
// Get this IP's request history
const requests = requestCounts.get(ip) || [];
// Filter to only requests within the time window
const recentRequests = requests.filter(time => now - time < windowMs);
if (recentRequests.length >= maxRequests) {
return res.status(429).json({
error: 'Too many requests. Please slow down.'
});
}
// Record this request
recentRequests.push(now);
requestCounts.set(ip, recentRequests);
next();
};
}
// Allow 10 requests per minute per IP
app.use('/api', rateLimiter(10, 60 * 1000));
Authentication Middleware#
This is where middleware really shines. Let's build authentication:
import jwt from 'jsonwebtoken'; // npm install jsonwebtoken
function authenticate(req, res, next) {
// 1. Get the token from the header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1]; // "Bearer <token>" → "<token>"
try {
// 2. Verify the token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// 3. Attach user info to the request
req.user = decoded;
// 4. Continue to the route
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
Now use it to protect routes:
// Public route - anyone can access
app.get('/api/posts', (req, res) => {
res.json({ posts: [] });
});
// Protected route - requires valid token
app.get('/api/profile', authenticate, (req, res) => {
// req.user is available because authenticate added it
res.json({ user: req.user });
});
// Protect ALL routes under a path
app.use('/api/admin', authenticate);
Adding Authorization (Roles)#
Authentication asks "Who are you?" Authorization asks "What can you do?"
function authorize(...allowedRoles) {
return (req, res, next) => {
// Must be authenticated first
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
// Check if user's role is allowed
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: 'Not authorized' });
}
next();
};
}
// Only admins can delete users
app.delete('/api/users/:id',
authenticate,
authorize('admin'),
(req, res) => {
// Delete user logic
}
);
// Admins and managers can view reports
app.get('/api/reports',
authenticate,
authorize('admin', 'manager'),
(req, res) => {
// Show reports
}
);
Error Handling Middleware#
Regular middleware has 3 parameters. Error middleware has 4 - the first one is the error:
function errorHandler(err, req, res, next) {
// Log the error for debugging
console.error('Error:', err.message);
console.error(err.stack);
// Send appropriate response
const statusCode = err.statusCode || 500;
const message = err.message || 'Something went wrong';
res.status(statusCode).json({ error: message });
}
Order Matters!
Error handlers MUST be defined AFTER all your routes. Express recognizes them by the 4 parameters.
How errors reach the error handler:
// Method 1: Throw an error
app.get('/broken', (req, res) => {
throw new Error('Oops!');
});
// Method 2: Call next with an error
app.get('/broken2', (req, res, next) => {
next(new Error('Oops!'));
});
// Method 3: Async errors (need wrapper)
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
app.get('/broken3', asyncHandler(async (req, res) => {
throw new Error('Async oops!');
}));
Better Error Handler#
function errorHandler(err, req, res, next) {
console.error(`[${req.id}] Error:`, err.message);
// Handle specific error types
if (err.name === 'ValidationError') {
return res.status(400).json({
error: 'Validation failed',
details: err.message
});
}
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'Invalid token' });
}
if (err.name === 'CastError') {
return res.status(400).json({ error: 'Invalid ID format' });
}
// Default error
res.status(err.statusCode || 500).json({
error: process.env.NODE_ENV === 'production'
? 'Something went wrong'
: err.message
});
}
404 Handler#
Handle requests that don't match any route:
function notFoundHandler(req, res) {
res.status(404).json({
error: `Cannot ${req.method} ${req.path}`
});
}
// Add AFTER all routes, BEFORE error handler
app.use('/api', apiRoutes);
app.use(notFoundHandler);
app.use(errorHandler);
The Right Order#
Middleware order is crucial. Here's the correct sequence:
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
const app = express();
// 1. Security headers (protect your app)
app.use(helmet());
// 2. CORS (allow cross-origin requests)
app.use(cors());
// 3. Logging (log requests early)
app.use(requestLogger);
// 4. Body parsing (before routes need req.body)
app.use(express.json());
// 5. Custom middleware (add request ID, etc.)
app.use(addRequestId);
// 6. Your routes
app.use('/api', apiRoutes);
// 7. 404 handler (when no route matches)
app.use(notFoundHandler);
// 8. Error handler (ALWAYS LAST)
app.use(errorHandler);
Why this order?
- Security headers should be set before anything else
- CORS needs to run before routes check authentication
- Logging should capture all requests
- Body parsing must happen before routes access
req.body - 404 handler catches unmatched routes
- Error handler catches everything that went wrong
Popular Third-Party Middleware#
Don't reinvent the wheel. These packages are battle-tested:
| Package | What It Does | Install |
|---|---|---|
cors | Enables cross-origin requests | npm install cors |
helmet | Sets security HTTP headers | npm install helmet |
morgan | HTTP request logger | npm install morgan |
compression | Compresses responses | npm install compression |
express-rate-limit | Rate limiting | npm install express-rate-limit |
Example production setup:
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import compression from 'compression';
const app = express();
app.use(helmet());
app.use(cors({ origin: process.env.FRONTEND_URL }));
app.use(morgan('combined')); // Detailed logs
app.use(compression());
app.use(express.json());
// Your routes...
Key Takeaways#
Middleware is Express's superpower. It lets you:
- Keep code DRY - Write common logic once, use everywhere
- Separate concerns - Each middleware does one thing
- Build features - Authentication, logging, rate limiting all work through middleware
- Handle errors - Centralize error handling in one place
The pattern is simple: receive request, do something, call next(). Master this and you've mastered Express.
The Middleware Mindset
Think of every cross-cutting concern as middleware: "Before handling any request, I need to _____." Log it? Parse the body? Check authentication? That's middleware.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.