Episode 3 — NodeJS MongoDB Backend Architecture / 3.6 — Middleware in Express
3.6.e — Custom Middleware Patterns
In one sentence: Custom middleware lets you encapsulate reusable logic -- logging, authentication, validation, timing, and more -- into composable functions, and patterns like middleware factories, async handlers, and
res.localsmake them flexible enough for any production Express application.
Navigation: <- 3.6.d Router-Level Middleware | 3.6.f -- Error Handling and Security Middleware ->
1. Writing a Request Logger Middleware
A logger records every request for debugging and monitoring.
// middleware/logger.js
const logger = (req, res, next) => {
const start = Date.now();
const timestamp = new Date().toISOString();
// Log when request comes in
console.log(`--> [${timestamp}] ${req.method} ${req.originalUrl}`);
console.log(` IP: ${req.ip} | User-Agent: ${req.get('User-Agent')}`);
// Log when response is sent
res.on('finish', () => {
const duration = Date.now() - start;
const statusColor = res.statusCode >= 400 ? '\x1b[31m' : '\x1b[32m';
const reset = '\x1b[0m';
console.log(
`<-- [${timestamp}] ${req.method} ${req.originalUrl} ` +
`${statusColor}${res.statusCode}${reset} (${duration}ms)`
);
});
next();
};
module.exports = logger;
Usage:
const logger = require('./middleware/logger');
app.use(logger);
app.get('/api/users', (req, res) => {
res.json({ users: [] });
});
Console output:
--> [2025-10-15T14:30:00.000Z] GET /api/users
IP: ::1 | User-Agent: Mozilla/5.0...
<-- [2025-10-15T14:30:00.000Z] GET /api/users 200 (5ms)
2. Writing an Authentication Middleware
Authentication middleware verifies credentials and attaches user data to the request.
// middleware/auth.js
const jwt = require('jsonwebtoken');
const authenticate = (req, res, next) => {
// 1. Extract token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({
error: 'Authentication required',
message: 'No Authorization header found'
});
}
if (!authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Invalid format',
message: 'Use: Bearer <token>'
});
}
const token = authHeader.split(' ')[1];
// 2. Verify token
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// 3. Attach user to request for downstream handlers
req.user = {
id: decoded.id,
email: decoded.email,
role: decoded.role
};
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'Invalid token' });
}
return res.status(500).json({ error: 'Authentication failed' });
}
};
module.exports = authenticate;
Usage:
const authenticate = require('./middleware/auth');
// Protected route -- authenticate runs first
app.get('/api/profile', authenticate, (req, res) => {
// req.user is available because authenticate set it
res.json({ user: req.user });
});
// Or protect an entire router
router.use(authenticate);
router.get('/settings', (req, res) => {
res.json({ userId: req.user.id, settings: {} });
});
3. Writing a Validation Middleware
Validation middleware checks that request data meets requirements before the route handler runs.
// middleware/validate.js
// Simple field presence check
const requireFields = (...fields) => {
return (req, res, next) => {
const missing = fields.filter(field => {
const value = req.body[field];
return value === undefined || value === null || value === '';
});
if (missing.length > 0) {
return res.status(400).json({
error: 'Validation failed',
missing: missing,
message: `Required fields: ${missing.join(', ')}`
});
}
next();
};
};
// Type checking
const validateTypes = (schema) => {
return (req, res, next) => {
const errors = [];
for (const [field, expectedType] of Object.entries(schema)) {
const value = req.body[field];
if (value !== undefined && typeof value !== expectedType) {
errors.push(`${field} must be ${expectedType}, got ${typeof value}`);
}
}
if (errors.length > 0) {
return res.status(400).json({
error: 'Type validation failed',
details: errors
});
}
next();
};
};
// Email format check
const validateEmail = (field = 'email') => {
return (req, res, next) => {
const email = req.body[field];
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (email && !emailRegex.test(email)) {
return res.status(400).json({
error: 'Invalid email format',
field: field
});
}
next();
};
};
module.exports = { requireFields, validateTypes, validateEmail };
Usage:
const { requireFields, validateTypes, validateEmail } = require('./middleware/validate');
app.post('/api/users',
requireFields('name', 'email', 'password'),
validateTypes({ name: 'string', email: 'string' }),
validateEmail('email'),
(req, res) => {
// If we get here, all validations passed
res.status(201).json({ message: 'User created', user: req.body });
}
);
app.post('/api/products',
requireFields('name', 'price'),
validateTypes({ name: 'string', price: 'number' }),
(req, res) => {
res.status(201).json({ product: req.body });
}
);
4. Middleware Factories -- Functions That Return Middleware
A middleware factory is a function that accepts configuration and returns a middleware function. This makes middleware reusable with different settings.
// Pattern: factory function returns middleware
const middlewareFactory = (options) => {
return (req, res, next) => {
// Use options to customize behavior
next();
};
};
Example 1: Role-based access control
// middleware/authorize.js
const requireRole = (...allowedRoles) => {
return (req, res, next) => {
// req.user was set by authenticate middleware
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: 'Forbidden',
message: `Requires role: ${allowedRoles.join(' or ')}`,
yourRole: req.user.role
});
}
next();
};
};
module.exports = requireRole;
const authenticate = require('./middleware/auth');
const requireRole = require('./middleware/authorize');
// Anyone authenticated can view
app.get('/api/users', authenticate, (req, res) => {
res.json({ users: [] });
});
// Only moderators and admins can edit
app.put('/api/users/:id',
authenticate,
requireRole('moderator', 'admin'),
(req, res) => {
res.json({ message: 'User updated' });
}
);
// Only admins can delete
app.delete('/api/users/:id',
authenticate,
requireRole('admin'),
(req, res) => {
res.json({ message: 'User deleted' });
}
);
Example 2: Configurable rate message
const withRateMessage = (resource) => {
return (req, res, next) => {
// Imagine rate-check logic here
const remaining = 10; // Would come from a store
if (remaining <= 0) {
return res.status(429).json({
error: `Too many requests for ${resource}`,
retryAfter: 60
});
}
res.setHeader('X-RateLimit-Remaining', remaining);
next();
};
};
app.get('/api/search', withRateMessage('search'), searchHandler);
app.post('/api/upload', withRateMessage('upload'), uploadHandler);
Example 3: Configurable cache headers
const cacheControl = (maxAge = 0) => {
return (req, res, next) => {
if (maxAge > 0) {
res.setHeader('Cache-Control', `public, max-age=${maxAge}`);
} else {
res.setHeader('Cache-Control', 'no-store');
}
next();
};
};
// Static data -- cache for 1 hour
app.get('/api/config', cacheControl(3600), getConfig);
// Dynamic data -- no cache
app.get('/api/notifications', cacheControl(0), getNotifications);
// Product listings -- cache for 5 minutes
app.get('/api/products', cacheControl(300), getProducts);
5. Async Middleware -- Handling Promises
Many middleware functions need to do async work (database queries, API calls). You must handle errors properly to avoid unhandled promise rejections.
The problem with raw async middleware
// DANGEROUS -- unhandled rejections crash the server!
app.use(async (req, res, next) => {
const user = await User.findById(req.userId); // If this throws...
req.user = user; // ...next() is never called
next(); // ...error is unhandled
});
Solution 1: try-catch in every async middleware
app.use(async (req, res, next) => {
try {
const user = await User.findById(req.userId);
req.user = user;
next();
} catch (err) {
next(err); // Pass error to error-handling middleware
}
});
Solution 2: async wrapper utility (recommended)
Write a helper function that wraps any async middleware with error handling.
// middleware/asyncHandler.js
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
module.exports = asyncHandler;
Usage -- clean and safe:
const asyncHandler = require('./middleware/asyncHandler');
// No try-catch needed -- errors automatically go to error middleware
app.get('/api/users', asyncHandler(async (req, res) => {
const users = await User.find();
res.json({ users });
}));
app.get('/api/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ user });
}));
app.post('/api/users', asyncHandler(async (req, res) => {
const user = await User.create(req.body);
res.status(201).json({ user });
}));
Async authentication middleware with the wrapper
const authenticate = asyncHandler(async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Token required' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.id);
if (!user) {
return res.status(401).json({ error: 'User no longer exists' });
}
req.user = user;
next();
});
6. Chaining Multiple Custom Middleware on a Single Route
Express allows you to stack as many middleware functions as needed on a single route.
const authenticate = require('./middleware/auth');
const requireRole = require('./middleware/authorize');
const { requireFields, validateEmail } = require('./middleware/validate');
const asyncHandler = require('./middleware/asyncHandler');
// POST /api/admin/users -- create a user (admin only)
app.post('/api/admin/users',
authenticate, // 1. Verify JWT
requireRole('admin'), // 2. Check admin role
requireFields('name', 'email', 'role'), // 3. Validate required fields
validateEmail('email'), // 4. Validate email format
asyncHandler(async (req, res) => { // 5. Create user
const user = await User.create(req.body);
res.status(201).json({ user });
})
);
Execution flow:
Request arrives
|
v
authenticate --> Is there a valid JWT?
| NO --> 401 Unauthorized (stops here)
| YES
v
requireRole('admin') --> Is user.role === 'admin'?
| NO --> 403 Forbidden (stops here)
| YES
v
requireFields(...) --> Are name, email, role present?
| NO --> 400 Bad Request (stops here)
| YES
v
validateEmail(...) --> Is email format valid?
| NO --> 400 Bad Request (stops here)
| YES
v
Route handler --> Create user, respond 201
7. res.locals -- Sharing Data Between Middleware
res.locals is an object that lives for the lifetime of the request. Any middleware can write to it, and the route handler (or later middleware) can read from it. It is the official way to pass data through the middleware chain.
// Middleware 1: Get user from token
app.use(asyncHandler(async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (token) {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
res.locals.user = await User.findById(decoded.id);
}
next();
}));
// Middleware 2: Get user's permissions
app.use(asyncHandler(async (req, res, next) => {
if (res.locals.user) {
res.locals.permissions = await Permission.findByRole(res.locals.user.role);
}
next();
}));
// Route handler: Use both
app.get('/api/dashboard', (req, res) => {
if (!res.locals.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
res.json({
user: res.locals.user,
permissions: res.locals.permissions,
canDelete: res.locals.permissions?.includes('delete')
});
});
res.locals vs req.customProperty
| Approach | Convention | When to Use |
|---|---|---|
req.user | Common for user info | Widely accepted for auth data |
res.locals | Official Express pattern | Sharing processed data between middleware |
req.customProp | Ad-hoc | Quick additions (e.g., req.startTime) |
Best practice: Use req.user for authentication (it is nearly universal). Use res.locals for everything else you need to share through the chain.
// Common convention
req.user = decodedUser; // Auth data
res.locals.permissions = perms; // Processed data
res.locals.pagination = { // Computed data
page: 1,
limit: 20,
total: 100
};
8. Real-World Pattern: Request Timing
// middleware/timing.js
const timing = (req, res, next) => {
const start = process.hrtime.bigint();
res.on('finish', () => {
const end = process.hrtime.bigint();
const durationNs = end - start;
const durationMs = Number(durationNs) / 1e6;
// Set a custom header
// Note: headers are already sent by the time 'finish' fires,
// so we log instead
console.log(
`[Timing] ${req.method} ${req.originalUrl} ` +
`${res.statusCode} ${durationMs.toFixed(2)}ms`
);
});
// You CAN set the header before the response is sent:
const originalJson = res.json.bind(res);
res.json = (body) => {
const end = process.hrtime.bigint();
const durationMs = Number(end - start) / 1e6;
res.setHeader('X-Response-Time', `${durationMs.toFixed(2)}ms`);
return originalJson(body);
};
next();
};
module.exports = timing;
9. Real-World Pattern: API Versioning
// middleware/apiVersion.js
const apiVersion = (defaultVersion = '1') => {
return (req, res, next) => {
// Check for version in header, query, or URL
const version =
req.headers['x-api-version'] ||
req.headers['accept-version'] ||
req.query.v ||
defaultVersion;
req.apiVersion = version;
res.setHeader('X-API-Version', version);
next();
};
};
module.exports = apiVersion;
const apiVersion = require('./middleware/apiVersion');
app.use(apiVersion('2'));
app.get('/api/users', (req, res) => {
if (req.apiVersion === '1') {
// V1: simple array
return res.json([{ id: 1, name: 'Alice' }]);
}
// V2: wrapped response
res.json({
data: [{ id: 1, name: 'Alice' }],
meta: { version: req.apiVersion, count: 1 }
});
});
10. Real-World Pattern: Request Sanitization
// middleware/sanitize.js
const sanitize = (req, res, next) => {
// Trim all string values in body
if (req.body && typeof req.body === 'object') {
for (const key of Object.keys(req.body)) {
if (typeof req.body[key] === 'string') {
req.body[key] = req.body[key].trim();
}
}
}
// Trim query parameters
for (const key of Object.keys(req.query)) {
if (typeof req.query[key] === 'string') {
req.query[key] = req.query[key].trim();
}
}
next();
};
module.exports = sanitize;
app.use(express.json());
app.use(sanitize);
app.post('/api/users', (req, res) => {
// req.body.name is already trimmed
console.log(req.body.name); // "Alice" instead of " Alice "
res.json({ user: req.body });
});
11. Middleware Organization -- File Structure
middleware/
asyncHandler.js -- Wraps async functions for error handling
auth.js -- authenticate, requireRole
validate.js -- requireFields, validateTypes, validateEmail
logger.js -- Request logger
timing.js -- Response time tracking
sanitize.js -- Input trimming and cleanup
apiVersion.js -- API versioning
errorHandler.js -- Central error handler (see 3.6.f)
Each file exports one or more middleware functions:
// middleware/index.js -- Optional barrel export
module.exports = {
asyncHandler: require('./asyncHandler'),
authenticate: require('./auth').authenticate,
requireRole: require('./auth').requireRole,
logger: require('./logger'),
timing: require('./timing'),
sanitize: require('./sanitize'),
...require('./validate')
};
// Clean imports in server.js
const {
asyncHandler,
authenticate,
requireRole,
logger,
sanitize
} = require('./middleware');
Key Takeaways
- Custom middleware encapsulates reusable request-processing logic (logging, auth, validation, timing).
- Middleware factories (functions returning middleware) make middleware configurable:
requireRole('admin'),cacheControl(3600). - Async middleware must handle errors -- use
try/catchor theasyncHandlerwrapper to forward errors to Express error handling. - Chain multiple middleware on a single route for a clean separation of concerns: auth -> role check -> validation -> handler.
res.localsis the official way to share data between middleware and route handlers within a single request.- Keep middleware in a dedicated
middleware/directory, one concern per file.
Explain-It Challenge
Explain without notes:
- What is a middleware factory? Write a pseudo-code example of one.
- Why is
asyncHandleruseful? What problem does it solve that plainasync (req, res, next)does not? - When would you use
res.localsinstead of directly attaching a property toreq? - A POST endpoint needs to: verify JWT, check admin role, validate 3 required fields, and create a database record. Describe the middleware chain.
Navigation: <- 3.6.d Router-Level Middleware | 3.6.f -- Error Handling and Security Middleware ->