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 (6)
1) The first thing you need when building an Express app is an asyncHandler middleware for handling exceptions inside async routes and passing them to your error handlers
2) When it comes to error handling, we’re mostly talking about exceptions such as resource not found, access denied, and similar cases:
3) For validations, I recommend sticking with a popular solution like
zod
,yup
, orclass-validator
. In general, you can use a simple middleware function that accepts a schema for the body, query parameters, etc., and throws aBadRequestException
if something is invalid.4) For logging and error tracking, I recommend tools like Sentry, PostHog, or Winston etc... Honestly, it’s a matter of taste and what you want to achieve.
5) Rate limiting should be implemented based on what you want to achieve. If you only want to limit request rates globally, do it at the web server level using Nginx or Apache. If you need to limit usage per API key or per user ID, then use Express middleware.
awesome! thank you for the feedback Mykhailo! appreciate it.
It's often pushed that we shouldn't try to 'reinvent the wheel' and if something has already been built, we should use that to build from. Sticking to tried and true methods, after all, saves an incredible amount of time and energy that could be better spent on other things. Building from the work of those that came before us inspires innovation.
But that simply isn't the case for every scenario (or even most), and trying to apply this philosophy as an absolute promotes "good enough" over a nuanced understanding of a project's needs. After all, if you rely solely on somebody else's work to do your own, you introduce points of failure and vulnerabilities outside of your control, as well as limit the granular control you have over how your software behaves. Each approach comes with its own tradeoffs, so it's important to understand what you're trying to do, and whether using existing tools over creating your own is the best approach.
I really appreciate what you've demonstrated here, @sisproid . I feel like regardless of the approach you take (using pre-existing libraries/tools or writing your own), if you don't understand how to do it yourself, then you don't fully understand your application.
Now of course, I'm not claiming that any of us know how every framework, library, or tool that we use works on the inside or even that we should always write our own. I am saying, however, that I believe being versed enough to create your own middleware is an incredibly valuable skill to have, regardless of whether other options exist that can do the job quicker. Sometimes it isn't about how quickly something can be glossed over, but instead how true to the purpose of the project the approach is. Great post!
I can't agree more with you, Derek. I somehow enjoy the feeling of having full control and knowing every corner of the code myself. That's why I try to use as few dependencies as possible for all things. Or maybe it was just my tech debt, actually. lol. thank you for the comment. appreciate it.
Nice Article
Thank you, Eli.