The built-in middlewares solve generic problems, but your app has specific needs. You need to check if users are admins, validate that request bodies have the right shape, or time how long database queries take. This is where custom middlewares shine, they let you encode your business logic into reusable, composable pieces.
AuthenticationWhat is authentication?Verifying who a user is, typically through credentials like a password or token. middlewareWhat is middleware?A function that runs between receiving a request and sending a response. It can check authentication, log data, or modify the request before your main code sees it.
Almost every app needs to know who's making a request. Here's a practical authentication middleware that verifies JWTWhat is jwt?JSON Web Token - a self-contained, signed token that carries user data (like user ID and role). The server can verify it without a database lookup. tokens.
// middleware/auth.js
import jwt from 'jsonwebtoken';
// Require valid authentication
export const requireAuth = (req, res, next) => {
const authHeader = req.headers.authorization;
// Check if header exists and starts with "Bearer "
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Authentication token missing'
});
}
// Extract token from "Bearer <token>"
const token = authHeader.substring(7);
try {
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Attach user ID to request for downstream use
req.userId = decoded.userId;
next();
} catch (err) {
return res.status(401).json({
error: 'Invalid or expired token'
});
}
};Sometimes you want authentication to be optional, if they have a tokenWhat is token?The smallest unit of text an LLM processes - roughly three-quarters of a word. API pricing is based on how many tokens you use., great, attach the user. If not, continue anyway. Here's a variant:
// Load user if token exists, but don't require it
export const loadUser = async (req, res, next) => {
const token = req.headers.authorization?.substring(7);
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.userId);
} catch {
// Invalid token, but we don't stop the request
// Just continue without user attached
}
}
next();
};Validation middlewareWhat is middleware?A function that runs between receiving a request and sending a response. It can check authentication, log data, or modify the request before your main code sees it. with ZodWhat is zod?A TypeScript-first schema validation library that validates data at runtime while automatically inferring static TypeScript types from the schema.
Never trust data coming from clients. Validation middleware ensures requests have the right structure before they reach your route handlers.
// middleware/validate.js
import { z } from 'zod';
export const validate = (schema) => {
return (req, res, next) => {
try {
// Validate body, query, and params against schema
schema.parse({
body: req.body,
query: req.query,
params: req.params
});
next();
} catch (err) {
if (err instanceof z.ZodError) {
// Format Zod errors nicely
return res.status(400).json({
error: 'Validation failed',
details: err.errors.map(e => ({
field: e.path.join('.'),
message: e.message
}))
});
}
// Unknown error, pass to error handler
next(err);
}
};
};
// Usage in routes
const createUserSchema = z.object({
body: z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(2, 'Name is too short')
})
});
app.post('/users', validate(createUserSchema), createUser);Zod gives you TypeScript-like validation at runtimeWhat is runtime?The environment that runs your code after it's written. Some languages need a runtime installed on the machine; others (like Go) bake it into the binary.. If the client sends invalid data, they get a clear error message before your route handlerWhat is route handler?A Next.js file named route.js inside the app/ directory that handles HTTP requests directly - the App Router equivalent of API routes. even runs.
Request logging with unique IDs
When debugging production issues, you need to trace a single request through your entire system. Adding a unique ID to each request makes this possible.
// middleware/requestLogger.js
import { v4 as uuidv4 } from 'uuid';
export const requestLogger = (req, res, next) => {
// Generate unique ID for this request
req.id = uuidv4();
const startTime = Date.now();
console.log(`[${req.id}] ${req.method} ${req.url} - Start`);
// Log when response is finished
res.on('finish', () => {
const duration = Date.now() - startTime;
console.log(
`[${req.id}] ${req.method} ${req.url} - ${res.statusCode} - ${duration}ms`
);
});
next();
};Now every log entry includes the request ID, making it easy to grep for all logs related to a specific request.
Async handler wrapper
Express doesn't catch errors in async functions automatically. You have three options: try/catch everywhere, use a wrapper, or use Express 5 (which handles this natively).
Here's the wrapper approach:
// middleware/asyncHandler.js
export const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
// Usage - no try/catch needed!
app.get('/users', asyncHandler(async (req, res) => {
const users = await User.findAll();
res.json(users);
}));
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError('User not found');
}
res.json(user);
}));The wrapper catches any rejected promiseWhat is promise?An object that represents a value you don't have yet but will get in the future, letting your code keep running while it waits. and passes it to next(), which sends it to your error handling middlewareWhat is middleware?A function that runs between receiving a request and sending a response. It can check authentication, log data, or modify the request before your main code sees it..
Request timing middlewareWhat is middleware?A function that runs between receiving a request and sending a response. It can check authentication, log data, or modify the request before your main code sees it.
Performance monitoring starts with knowing how long things take. This middleware adds timing capabilities to every request.
// middleware/timing.js
export const addTiming = (req, res, next) => {
req.startTime = Date.now();
// Helper method to get elapsed time
req.getDuration = () => Date.now() - req.startTime;
// Add response time header
res.on('finish', () => {
res.setHeader('X-Response-Time', `${req.getDuration()}ms`);
});
next();
};Now your routes can check timing:
app.get('/slow-query', async (req, res) => {
const results = await database.heavyQuery();
console.log(`Query took ${req.getDuration()}ms`);
res.json(results);
});Maintenance mode middlewareWhat is middleware?A function that runs between receiving a request and sending a response. It can check authentication, log data, or modify the request before your main code sees it.
Sometimes you need to take your APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. down for maintenance. Instead of deploying new code, use an environment variableWhat is environment variable?A value stored outside your code that configures behavior per deployment, commonly used for secrets like API keys and database URLs. and a middleware.
// middleware/maintenance.js
export const maintenanceMode = (req, res, next) => {
if (process.env.MAINTENANCE_MODE === 'true') {
return res.status(503).json({
error: 'Maintenance in progress',
message: 'We are performing scheduled maintenance. Please try again later.',
retryAfter: 3600 // Suggest client retry in 1 hour
});
}
next();
};Set MAINTENANCE_MODE=true and your API immediately returns 503 for all requests. No deployment needed.
APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. versioning middlewareWhat is middleware?A function that runs between receiving a request and sending a response. It can check authentication, log data, or modify the request before your main code sees it.
As your API evolves, you'll need to support multiple versions. This middleware attaches version info to requests.
// middleware/version.js
export const apiVersion = (version) => {
return (req, res, next) => {
req.apiVersion = version;
res.setHeader('X-API-Version', version);
next();
};
};
// Routes
app.use('/api/v1', apiVersion('1.0.0'), v1Router);
app.use('/api/v2', apiVersion('2.0.0'), v2Router);Your route handlers can check req.apiVersion to vary behavior between versions.
Organizing your middlewares
As you create more middlewares, you'll want a clean way to import them. Create an indexWhat is index?A data structure the database maintains alongside a table so it can find rows by specific columns quickly instead of scanning everything. file:
// middleware/index.js
export { requireAuth, loadUser } from './auth.js';
export { validate } from './validate.js';
export { requestLogger } from './requestLogger.js';
export { asyncHandler } from './asyncHandler.js';
export { errorHandler } from './errorHandler.js';Then use them cleanly in your app:
import { requireAuth, validate, requestLogger } from './middleware/index.js';
app.use(requestLogger);
app.use('/api/protected', requireAuth);Quick reference: custom middlewareWhat is middleware?A function that runs between receiving a request and sending a response. It can check authentication, log data, or modify the request before your main code sees it. patterns
| Pattern | When to use | Key feature |
|---|---|---|
| Authentication | Protecting routes | Verifies tokens, attaches user data |
| Validation | Input sanitization | Rejects malformed requests early |
| Logging | Debugging, monitoring | Adds request IDs and timing |
| Async wrapper | Error handling | Catches async errors automatically |
| Feature flags | Maintenance mode | Checks environment variables |
Building your own middlewares is where Express goes from a simple framework to a powerful toolkit. Start with these patterns, then create your own as your app's needs evolve.