Hey fellow developers! π I've been wrestling with Express.js middleware for years, and I finally put together something that doesn't make me want to pull my hair out every time I start a new project. Let me share what I've learned.
You know that feeling when you're starting a new Express.js project and you're like, "Alright, time to set up middleware... again"? And then you spend the next 3 hours googling "express middleware best practices" for the hundredth time, copying random snippets from Stack Overflow, and hoping they play nice together?
Yeah, I was there too. Until I got fed up and decided to build a middleware system that actually makes sense and works consistently across projects. Today, I'm sharing exactly how I did it β and trust me, your future self will thank you.
Why This Matters (And Why Most Middleware Sucks)
Here's the thing: most Express.js tutorials show you cute little middleware examples that work great in isolation but fall apart the moment you try to use them in a real application. You'll see things like:
// This is what tutorials show you
app.use((req, res, next) => {
console.log('Hello World!');
next();
});
Cool, but what about error handling? What about validation? What about authentication that doesn't break when you look at it wrong? What about middleware that actually helps you build something production-ready?
That's where this guide comes in. I've built middleware that:
- Actually handles errors properly (shocking, I know)
- Validates data without making you cry
- Handles authentication like a grown-up application
- Plays nice with other middleware
- Doesn't mysteriously break in production
The Foundation: Request Logging That Actually Helps
Let's start with something simple but incredibly useful β request logging that tells you what's actually happening:
export const requestLogger = (req, res, next) => {
const start = Date.now();
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
// Here's the magic: override res.end to capture response time
const originalEnd = res.end;
res.end = function(...args) {
const duration = Date.now() - start;
console.log(`${req.method} ${req.path} - ${res.statusCode} - ${duration}ms`);
originalEnd.apply(this, args);
};
next();
};
Why this rocks: Instead of just logging when requests come in, this tells you how long they took and what status code they returned. When something's running slow at 3 AM, you'll know exactly which endpoint is the culprit.
The trick here is overriding res.end()
β that's the final method Express calls when sending a response, so we can measure the total time accurately.
Error Handling That Doesn't Suck
Here's where most people mess up. They either don't handle errors at all, or they have some janky error handler that sometimes works. Here's what actually works:
export const errorHandler = (err, req, res, next) => {
console.error(`Error: ${err.message}`);
console.error(err.stack);
let statusCode = 500;
let message = 'Internal Server Error';
// Handle different types of errors properly
if (err.name === 'ValidationError') {
statusCode = 400;
message = 'Invalid input data';
} else if (err.name === 'UnauthorizedError') {
statusCode = 401;
message = 'Unauthorized access';
} else if (err.statusCode) {
statusCode = err.statusCode;
message = err.message;
}
res.status(statusCode).json({
error: {
message,
// Only show stack trace in development
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
};
Why this works: It recognizes different error types and responds appropriately. No more generic 500 errors that tell you nothing. Your API clients will actually know what went wrong.
The key insight here is having a consistent error format and being smart about what information you expose in different environments.
Validation That Doesn't Make You Want to Quit
I've seen so many validation approaches that are either overly complex or completely inadequate. Here's a middle-ground approach that actually works:
export const createValidator = (rules) => {
return (data) => {
const errors = [];
for (const [field, rule] of Object.entries(rules)) {
const value = data[field];
if (rule.required && (value === undefined || value === null || value === '')) {
errors.push(`${field} is required`);
continue;
}
if (value !== undefined && rule.type && typeof value !== rule.type) {
errors.push(`${field} must be a ${rule.type}`);
}
if (value && rule.minLength && value.length < rule.minLength) {
errors.push(`${field} must be at least ${rule.minLength} characters`);
}
if (value && rule.pattern && !rule.pattern.test(value)) {
errors.push(`${field} format is invalid`);
}
}
return {
isValid: errors.length === 0,
errors,
data
};
};
};
And here's how you use it:
const userSchema = createValidator({
username: { required: true, type: 'string', minLength: 3 },
email: {
required: true,
type: 'string',
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
},
age: { type: 'number' }
});
app.post('/users', validateBody(userSchema), (req, res) => {
// req.validatedBody contains clean, validated data
res.json({ message: 'User created', data: req.validatedBody });
});
Why I love this: It's simple enough to understand at a glance, flexible enough to handle most validation needs, and gives you clear error messages. No need to learn a whole validation library for basic use cases.
Authentication That Actually Secures Things
Authentication middleware is where things usually get messy. Here's a clean approach:
export const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: { message: 'No valid authentication token provided' }
});
}
const token = authHeader.substring(7);
// Replace this with your actual token validation
if (validateToken(token)) {
req.user = getUserFromToken(token);
next();
} else {
res.status(401).json({
error: { message: 'Invalid authentication token' }
});
}
};
And for authorization:
export const authorize = (...roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
error: { message: 'Authentication required' }
});
}
if (roles.length && !roles.includes(req.user.role)) {
return res.status(403).json({
error: { message: 'Insufficient permissions' }
});
}
next();
};
};
Usage example:
// Protected route
app.get('/profile', authenticate, (req, res) => {
res.json({ user: req.user });
});
// Admin-only route
app.delete('/users/:id', authenticate, authorize('admin'), (req, res) => {
res.json({ message: 'User deleted' });
});
What makes this work: Clear separation between authentication (who are you?) and authorization (what can you do?). The middleware decorates the request with user info that downstream handlers can use.
Rate Limiting That Actually Prevents Abuse
Most rate limiting examples you see are either too simplistic or require Redis. Here's a practical in-memory solution that works great for most applications:
const requestCounts = new Map();
export const rateLimitMiddleware = (options = {}) => {
const {
windowMs = 15 * 60 * 1000, // 15 minutes
max = 100,
message = 'Too many requests, please try again later'
} = options;
return (req, res, next) => {
const key = req.ip || req.connection.remoteAddress;
const now = Date.now();
// Clean up old entries (prevents memory leaks)
for (const [ip, data] of requestCounts.entries()) {
if (now - data.resetTime > windowMs) {
requestCounts.delete(ip);
}
}
if (!requestCounts.has(key)) {
requestCounts.set(key, { count: 0, resetTime: now });
}
const counter = requestCounts.get(key);
if (now - counter.resetTime > windowMs) {
counter.count = 0;
counter.resetTime = now;
}
counter.count++;
// Set standard rate limit headers
res.set({
'X-RateLimit-Limit': max,
'X-RateLimit-Remaining': Math.max(0, max - counter.count),
'X-RateLimit-Reset': new Date(counter.resetTime + windowMs)
});
if (counter.count > max) {
return res.status(429).json({
error: {
message,
retryAfter: Math.ceil((counter.resetTime + windowMs - now) / 1000)
}
});
}
next();
};
};
Why this approach works: It's stateless (no external dependencies), automatically cleans up old entries, follows HTTP standards for rate limiting headers, and gives clients clear information about when they can try again.
Putting It All Together
Here's how you'd use all of this in a real application:
import express from 'express';
import {
setupMiddleware,
errorHandler,
notFoundHandler,
authenticate,
authorize,
validateBody,
createValidator,
rateLimitMiddleware
} from './middleware/index.js';
const app = express();
// Basic middleware setup
setupMiddleware(app);
// Public endpoint
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Validated endpoint
const userSchema = createValidator({
username: { required: true, type: 'string', minLength: 3 },
email: { required: true, type: 'string', pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
});
app.post('/api/users', validateBody(userSchema), (req, res) => {
res.json({ message: 'User created', data: req.validatedBody });
});
// Protected endpoint
app.get('/api/profile', authenticate, (req, res) => {
res.json({ user: req.user });
});
// Admin endpoint with extra rate limiting
app.get('/api/admin/users',
authenticate,
authorize('admin'),
rateLimitMiddleware({ windowMs: 10 * 60 * 1000, max: 10 }),
(req, res) => {
res.json({ message: 'Admin data' });
}
);
// Error handling (always last!)
app.use(notFoundHandler);
app.use(errorHandler);
app.listen(3000, () => {
console.log('Server running on port 3000');
});
The Secret Sauce: Middleware Ordering
Here's something that trips up a lot of developers β order matters. A lot. Here's the order that actually works:
- Security stuff first (CORS, security headers)
- Rate limiting (before parsing, so you don't waste CPU on bad requests)
- Body parsing (so other middleware can access req.body)
- Logging (after parsing, so you can log request data)
- Authentication (before routes that need it)
- Your routes
- 404 handler
- Error handler (always last)
Get this wrong, and you'll spend hours debugging why your middleware isn't working.
Real Talk: What This Gets You
After implementing this system across several projects, here's what I've noticed:
β Debugging is actually possible β When something breaks, the logs tell you exactly what happened and where.
β Onboarding new developers is smoother β The middleware is self-documenting and follows predictable patterns.
β Security is built-in β Authentication, authorization, and rate limiting are consistent across all endpoints.
β Testing is straightforward β Each middleware has a single responsibility and can be tested in isolation.
β Production deployment is less scary β Error handling is consistent, logging is comprehensive, and rate limiting prevents most abuse.
Where to Go From Here
This system handles about 80% of what most applications need. As you grow, you might want to add:
- Database-backed rate limiting (Redis) for multi-instance deployments
- JWT token validation instead of simple token checking
- Request correlation IDs for tracking requests across services
- Metric collection for monitoring and alerting
- Content compression and caching middleware
But honestly? Start with this. It's production-ready, well-tested, and will serve you well until you have specific reasons to add complexity.
The best part? This isn't some framework-specific magic. It's just good old Express.js middleware done right. No dependencies, no vendor lock-in, just solid fundamentals that will work for years to come.
What do you think? Have you built similar middleware systems? What patterns have worked (or failed spectacularly) for you? Drop a comment β I'd love to hear about your experiences!
And if this helped you out, give it a clap π and share it with your fellow developers. We've all wasted too much time on middleware that doesn't work properly.
Happy coding! π
P.S. β If you're working on a team, seriously consider standardizing on something like this. Future you (and your teammates) will thank you when you're not debugging middleware interactions at 2 AM.
Top comments (0)