Express.js Middleware Deep Dive

In the previous post, you built your first Express server and got a taste of middleware. Now it's time to go deeper.
Middleware is the backbone of every Express application. Understanding how it works — and how to wield it effectively — is what separates beginner Express code from production-grade code.
What You'll Learn
Learning Outcomes:
✅ Understand exactly how middleware works under the hood
✅ Know the difference between application-level and router-level middleware
✅ Create custom middleware for logging, authentication, and validation
✅ Integrate popular third-party middleware (morgan, cors, helmet, compression)
✅ Implement centralized error handling with custom error classes
✅ Handle async errors properly without crashing your server
How Middleware Works
A middleware function is any function with this signature:
function middleware(req, res, next) {
// 1. Do something (modify req/res, validate, log, etc.)
// 2. Either call next() OR send a response
next();
}When a request comes in, Express runs each middleware function in the order you registered it. Each middleware either:
- Calls
next()to pass control to the next middleware/route - Sends a response to end the cycle (
res.json(),res.send(), etc.)
The Execution Flow
If any middleware sends a response, the chain stops — subsequent middleware and route handlers are skipped.
Application-level vs Router-level
Application-level middleware runs on every request (or a path prefix):
const app = express();
// Runs on ALL requests
app.use(express.json());
// Runs only on requests to /api/*
app.use('/api', authMiddleware);Router-level middleware is scoped to an Express Router instance:
const router = express.Router();
// Only applies to this router's routes
router.use(validateApiKey);
router.get('/users', getUsers);
router.post('/users', createUser);Router-level middleware is the key to building modular, maintainable apps. We'll use it heavily in the next post.
Built-in Middleware
Express 4.x includes several built-in middleware functions — no extra packages needed.
express.json()
Parses incoming requests with JSON bodies. Without it, req.body is undefined.
app.use(express.json());
app.post('/todos', (req, res) => {
console.log(req.body); // { title: "Learn Express" }
res.status(201).json({ message: 'Created' });
});You can configure limits:
app.use(express.json({ limit: '10mb' })); // Max body sizeexpress.urlencoded()
Parses URL-encoded form data (application/x-www-form-urlencoded), the format HTML forms use by default.
app.use(express.urlencoded({ extended: true }));
app.post('/contact', (req, res) => {
const { name, email, message } = req.body;
// Process form data
});The extended: true option enables parsing of nested objects. Use false for simple key-value pairs.
express.static()
Serves static files (HTML, CSS, images, etc.) from a directory:
// Serve files from "public" folder
app.use(express.static('public'));
// With a URL prefix
app.use('/assets', express.static('public'));
// With options
app.use(express.static('public', {
maxAge: '1d', // Cache for 1 day
etag: false, // Disable ETags
}));express.raw() and express.text()
For specialized use cases:
// Parse raw binary data (e.g., file uploads, webhooks)
app.use('/webhooks', express.raw({ type: 'application/json' }));
// Parse plain text bodies
app.use(express.text({ type: 'text/plain' }));Custom Middleware
This is where the real power of Express comes in. You can create middleware for any cross-cutting concern in your application.
Request Logging Middleware
A simple logger that records method, URL, and duration:
function requestLogger(req, res, next) {
const start = Date.now();
// Listen for the response to finish
res.on('finish', () => {
const duration = Date.now() - start;
console.log(
`${req.method} ${req.originalUrl} ${res.statusCode} - ${duration}ms`
);
});
next();
}
app.use(requestLogger);Using res.on('finish') is key — it lets you log the status code after the response is actually sent, not before.
Request ID Middleware
Assign a unique ID to each request for tracing:
const { randomUUID } = require('crypto');
function requestId(req, res, next) {
req.id = randomUUID();
res.set('X-Request-ID', req.id);
next();
}
app.use(requestId);
// Now every req.id is unique — useful for log correlationAuthentication Middleware
Protect routes by verifying a token:
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
message: 'No token provided',
});
}
const token = authHeader.split(' ')[1];
try {
// In a real app, verify a JWT here
// const decoded = jwt.verify(token, process.env.JWT_SECRET);
// req.user = decoded;
// For demo purposes:
if (token !== 'demo-token') {
throw new Error('Invalid token');
}
req.user = { id: 1, email: 'user@example.com' };
next();
} catch (error) {
return res.status(401).json({
success: false,
message: 'Invalid or expired token',
});
}
}
// Apply to specific routes
app.get('/api/profile', authenticate, (req, res) => {
res.json({ user: req.user });
});
// Or apply to all routes under a prefix
app.use('/api/protected', authenticate);Request Validation Middleware
Validate required fields before reaching your route handler:
function validateTodo(req, res, next) {
const { title } = req.body;
const errors = [];
if (!title) {
errors.push('title is required');
} else if (typeof title !== 'string') {
errors.push('title must be a string');
} else if (title.length > 200) {
errors.push('title must be 200 characters or fewer');
}
if (errors.length > 0) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors,
});
}
next();
}
app.post('/api/todos', validateTodo, (req, res) => {
// We know req.body.title is valid here
const todo = createTodo(req.body.title);
res.status(201).json({ success: true, data: todo });
});Middleware Factories
A middleware factory is a function that returns middleware. This is useful when your middleware needs to be configurable:
// Factory: create middleware that checks for specific roles
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({
message: `Requires one of these roles: ${roles.join(', ')}`,
});
}
next();
};
}
// Usage
app.delete('/api/users/:id', authenticate, requireRole('admin'), deleteUser);
app.get('/api/reports', authenticate, requireRole('admin', 'manager'), getReports);// Factory: rate limiter per route
function rateLimit(maxRequests, windowMs) {
const requests = new Map();
return (req, res, next) => {
const key = req.ip;
const now = Date.now();
const windowStart = now - windowMs;
// Clean up old entries
const userRequests = (requests.get(key) || []).filter(
time => time > windowStart
);
if (userRequests.length >= maxRequests) {
return res.status(429).json({
success: false,
message: 'Too many requests. Please try again later.',
});
}
userRequests.push(now);
requests.set(key, userRequests);
next();
};
}
// 5 requests per minute for login
app.post('/api/auth/login', rateLimit(5, 60 * 1000), loginHandler);
// 100 requests per minute for general API
app.use('/api', rateLimit(100, 60 * 1000));Third-party Middleware
These packages handle common production concerns, battle-tested and ready to use.
morgan — HTTP Request Logger
Morgan logs HTTP requests in a standardized format. Much more powerful than a hand-rolled logger.
npm install morganconst morgan = require('morgan');
// Pre-defined formats
app.use(morgan('dev')); // Colorized, concise (great for dev)
app.use(morgan('combined')); // Apache combined log (great for prod)
app.use(morgan('short')); // Shorter than combined
app.use(morgan('tiny')); // Minimal output
// Custom format
app.use(morgan(':method :url :status :response-time ms - :res[content-length]'));Output of morgan('dev'):
GET /api/todos 200 4.823 ms - 245
POST /api/todos 201 2.156 ms - 89
GET /api/todos/999 404 1.203 ms - 47Write logs to a file in production:
const fs = require('fs');
const path = require('path');
// Create a write stream
const accessLogStream = fs.createWriteStream(
path.join(__dirname, 'logs', 'access.log'),
{ flags: 'a' } // Append mode
);
app.use(morgan('combined', { stream: accessLogStream }));cors — Cross-Origin Resource Sharing
Controls which domains can access your API from a browser.
npm install corsconst cors = require('cors');
// Allow ALL origins (not safe for production APIs with sensitive data)
app.use(cors());
// Allow specific origins
app.use(cors({
origin: 'https://myapp.com',
}));
// Allow multiple origins
app.use(cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
}));
// Dynamic origin check
app.use(cors({
origin: (origin, callback) => {
const allowedOrigins = ['https://myapp.com', 'https://staging.myapp.com'];
// Allow requests with no origin (e.g., mobile apps, curl)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true, // Allow cookies / Authorization headers
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));Apply CORS per route for fine-grained control:
// Public endpoint
app.get('/api/public', cors(), getPublicData);
// Restricted endpoint
app.get('/api/admin', cors({ origin: 'https://admin.myapp.com' }), getAdminData);helmet — Security Headers
Sets HTTP security headers to protect against common web vulnerabilities. Use it in every production Express app.
npm install helmetconst helmet = require('helmet');
// Enable all defaults (recommended)
app.use(helmet());helmet() sets these headers by default:
| Header | Protection |
|---|---|
Content-Security-Policy | XSS, clickjacking |
X-DNS-Prefetch-Control | DNS prefetch leaks |
X-Frame-Options | Clickjacking |
X-XSS-Protection | Legacy XSS filter |
X-Content-Type-Options | MIME sniffing |
Strict-Transport-Security | Forces HTTPS |
Referrer-Policy | Referrer leaks |
Customize individual protections:
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'cdn.jsdelivr.net'],
styleSrc: ["'self'", "'unsafe-inline'"],
},
},
crossOriginEmbedderPolicy: false, // Disable if embedding content
}));compression — Response Compression
Gzip/Brotli compress responses, dramatically reducing payload size.
npm install compressionconst compression = require('compression');
// Compress all responses
app.use(compression());
// Only compress responses larger than 1KB
app.use(compression({ threshold: 1024 }));
// Custom filter — don't compress certain content types
app.use(compression({
filter: (req, res) => {
// Don't compress if client explicitly opts out
if (req.headers['x-no-compression']) return false;
// Use default filter otherwise
return compression.filter(req, res);
},
}));Response size comparison (typical JSON payload):
- Without compression: ~50KB
- With gzip: ~8KB (84% smaller)
cookie-parser — Parse Cookies
Parses the Cookie header and populates req.cookies:
npm install cookie-parserconst cookieParser = require('cookie-parser');
app.use(cookieParser());
// Optionally, sign cookies with a secret
app.use(cookieParser('my-secret-key'));
// Read cookies
app.get('/profile', (req, res) => {
console.log(req.cookies.sessionId); // Unsigned cookie
console.log(req.signedCookies.userId); // Signed cookie
});
// Set cookies
app.post('/login', (req, res) => {
res.cookie('sessionId', 'abc123', {
httpOnly: true, // Not accessible via JS
secure: true, // HTTPS only
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
sameSite: 'strict',
});
res.json({ message: 'Logged in' });
});Error Handling Middleware
Error handling is the most important and most misunderstood part of Express middleware. Let's get it right.
The Error Handler Signature
Error-handling middleware takes four parameters — the extra err parameter is how Express knows it's an error handler:
// Regular middleware: 3 params
function middleware(req, res, next) { ... }
// Error handler: 4 params (err MUST be first)
function errorHandler(err, req, res, next) { ... }Critical rule: Always register error-handling middleware LAST, after all routes and other middleware.
Triggering Error Handlers
Pass an error to next() to skip all remaining regular middleware and jump to the error handler:
app.get('/users/:id', (req, res, next) => {
try {
const user = getUserById(req.params.id);
if (!user) {
// Pass an error to next()
const err = new Error('User not found');
err.status = 404;
return next(err);
}
res.json(user);
} catch (error) {
next(error); // Pass unexpected errors to error handler
}
});Custom Error Classes
Custom error classes make it easy to distinguish operational errors (bad input, not found) from programming errors (bugs):
// errors.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.isOperational = true; // Expected, user-facing errors
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
class ValidationError extends AppError {
constructor(message, errors = []) {
super(message, 400);
this.errors = errors;
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401);
}
}
class ForbiddenError extends AppError {
constructor(message = 'Access denied') {
super(message, 403);
}
}
module.exports = { AppError, NotFoundError, ValidationError, UnauthorizedError, ForbiddenError };Centralized Error Handler
One error handler to rule them all:
const { AppError } = require('./errors');
function errorHandler(err, req, res, next) {
// Default to 500 if no status code set
const statusCode = err.statusCode || err.status || 500;
const isProduction = process.env.NODE_ENV === 'production';
// Log the error (always)
if (statusCode >= 500) {
console.error('💥 Server error:', {
message: err.message,
stack: err.stack,
url: req.originalUrl,
method: req.method,
});
}
// Operational errors: safe to expose to client
if (err.isOperational) {
return res.status(statusCode).json({
success: false,
message: err.message,
...(err.errors && { errors: err.errors }), // Include field errors if present
});
}
// Programming/unexpected errors: hide details in production
res.status(500).json({
success: false,
message: isProduction ? 'Something went wrong' : err.message,
...(isProduction ? {} : { stack: err.stack }),
});
}
// Register LAST
app.use(errorHandler);Using the custom errors in routes:
const { NotFoundError, ValidationError, ForbiddenError } = require('./errors');
app.get('/api/todos/:id', (req, res, next) => {
try {
const todo = todos.find(t => t.id === parseInt(req.params.id));
if (!todo) throw new NotFoundError('Todo');
res.json({ success: true, data: todo });
} catch (error) {
next(error);
}
});
app.post('/api/todos', (req, res, next) => {
try {
const { title } = req.body;
if (!title) {
throw new ValidationError('Validation failed', ['title is required']);
}
const todo = { id: Date.now(), title, completed: false };
res.status(201).json({ success: true, data: todo });
} catch (error) {
next(error);
}
});Async Error Handling
Express 4.x does not automatically catch errors thrown inside async functions. You must catch them yourself:
// ❌ Wrong — unhandled promise rejection crashes the server
app.get('/api/users', async (req, res) => {
const users = await User.findAll(); // If this throws, Express won't catch it
res.json(users);
});
// ✅ Option 1 — try/catch in every async handler
app.get('/api/users', async (req, res, next) => {
try {
const users = await User.findAll();
res.json(users);
} catch (error) {
next(error); // Forward to error handler
}
});
// ✅ Option 2 — asyncHandler wrapper (eliminates boilerplate)
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
app.get('/api/users', asyncHandler(async (req, res) => {
const users = await User.findAll();
res.json(users);
}));
app.post('/api/todos', asyncHandler(async (req, res) => {
const todo = await Todo.create(req.body);
res.status(201).json({ success: true, data: todo });
}));The asyncHandler wrapper is a simple but powerful pattern. Many teams extract it to a shared utilities file and use it everywhere.
Note: Express 5 (currently in beta) automatically handles async errors, eliminating the need for
asyncHandler. For Express 4, the wrapper is the cleanest solution.
Handling 404s
A "not found" handler catches requests to undefined routes. Place it after all your routes but before the error handler:
// All routes defined above...
// 404 — no route matched
app.use((req, res, next) => {
next(new NotFoundError(`Route ${req.method} ${req.originalUrl}`));
});
// Error handler — must be last
app.use(errorHandler);Hands-on Project: Upgrading the Todo API
Let's upgrade the Todo API from Post 1 with production-grade middleware.
Project Structure
express-todo-api/
├── index.js
├── errors.js # Custom error classes
├── middleware/
│ ├── auth.js # Authentication
│ ├── logger.js # Request logging
│ └── validate.js # Input validation
└── package.jsonerrors.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
class ValidationError extends AppError {
constructor(message, errors = []) {
super(message, 400);
this.errors = errors;
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401);
}
}
module.exports = { AppError, NotFoundError, ValidationError, UnauthorizedError };middleware/logger.js
function requestLogger(req, res, next) {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const color =
res.statusCode >= 500 ? '\x1b[31m' : // Red
res.statusCode >= 400 ? '\x1b[33m' : // Yellow
res.statusCode >= 300 ? '\x1b[36m' : // Cyan
'\x1b[32m'; // Green
const reset = '\x1b[0m';
console.log(
`${color}${req.method}${reset} ${req.originalUrl} ` +
`${color}${res.statusCode}${reset} ${duration}ms`
);
});
next();
}
module.exports = requestLogger;middleware/auth.js
const { UnauthorizedError } = require('../errors');
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return next(new UnauthorizedError('No token provided'));
}
const token = authHeader.split(' ')[1];
try {
// Replace with real JWT verification:
// const decoded = jwt.verify(token, process.env.JWT_SECRET);
// req.user = decoded;
if (token !== 'demo-token') throw new Error('Invalid token');
req.user = { id: 1, email: 'demo@example.com' };
next();
} catch {
next(new UnauthorizedError('Invalid or expired token'));
}
}
module.exports = authenticate;middleware/validate.js
const { ValidationError } = require('../errors');
function validateTodo(req, res, next) {
const { title } = req.body;
const errors = [];
if (!title) {
errors.push('title is required');
} else if (typeof title !== 'string') {
errors.push('title must be a string');
} else if (title.trim().length === 0) {
errors.push('title cannot be empty');
} else if (title.length > 200) {
errors.push('title must be 200 characters or fewer');
}
if (errors.length > 0) {
return next(new ValidationError('Validation failed', errors));
}
// Sanitize
req.body.title = req.body.title.trim();
next();
}
module.exports = { validateTodo };index.js — Full Upgraded App
const express = require('express');
const morgan = require('morgan');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const requestLogger = require('./middleware/logger');
const authenticate = require('./middleware/auth');
const { validateTodo } = require('./middleware/validate');
const { AppError, NotFoundError } = require('./errors');
const app = express();
const PORT = process.env.PORT || 3000;
// ─── Security & Performance ─────────────────────────────────────────────
app.use(helmet());
app.use(compression());
// ─── CORS ───────────────────────────────────────────────────────────────
app.use(cors({
origin: process.env.ALLOWED_ORIGIN || '*',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));
// ─── Body Parsing ────────────────────────────────────────────────────────
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
// ─── Logging ─────────────────────────────────────────────────────────────
app.use(morgan('dev')); // HTTP request logging
app.use(requestLogger); // Custom timing logger
// ─── In-memory Database ─────────────────────────────────────────────────
let todos = [
{ id: 1, title: 'Learn Express.js', completed: false, createdAt: new Date() },
{ id: 2, title: 'Master Middleware', completed: false, createdAt: new Date() },
];
let nextId = 3;
// ─── Public Routes ───────────────────────────────────────────────────────
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date() });
});
// ─── Protected Routes ────────────────────────────────────────────────────
// GET /api/todos
app.get('/api/todos', authenticate, (req, res) => {
const { completed } = req.query;
let result = todos;
if (completed !== undefined) {
result = todos.filter(t => t.completed === (completed === 'true'));
}
res.json({ success: true, data: result, total: result.length });
});
// GET /api/todos/:id
app.get('/api/todos/:id', authenticate, (req, res, next) => {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return next(new AppError('Invalid ID format', 400));
}
const todo = todos.find(t => t.id === id);
if (!todo) return next(new NotFoundError('Todo'));
res.json({ success: true, data: todo });
});
// POST /api/todos
app.post('/api/todos', authenticate, validateTodo, (req, res) => {
const todo = {
id: nextId++,
title: req.body.title,
completed: false,
createdAt: new Date(),
};
todos.push(todo);
res.status(201).json({ success: true, data: todo });
});
// PUT /api/todos/:id
app.put('/api/todos/:id', authenticate, (req, res, next) => {
const id = parseInt(req.params.id);
const index = todos.findIndex(t => t.id === id);
if (index === -1) return next(new NotFoundError('Todo'));
const { title, completed } = req.body;
if (title !== undefined) todos[index].title = title.trim();
if (completed !== undefined) todos[index].completed = Boolean(completed);
res.json({ success: true, data: todos[index] });
});
// DELETE /api/todos/:id
app.delete('/api/todos/:id', authenticate, (req, res, next) => {
const id = parseInt(req.params.id);
const index = todos.findIndex(t => t.id === id);
if (index === -1) return next(new NotFoundError('Todo'));
const [deleted] = todos.splice(index, 1);
res.json({ success: true, data: deleted });
});
// ─── 404 Handler ─────────────────────────────────────────────────────────
app.use((req, res, next) => {
next(new NotFoundError(`Route ${req.method} ${req.originalUrl}`));
});
// ─── Error Handler (always last) ─────────────────────────────────────────
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const isProduction = process.env.NODE_ENV === 'production';
if (statusCode >= 500) {
console.error('💥 Error:', err.message, '\n', err.stack);
}
if (err.isOperational) {
return res.status(statusCode).json({
success: false,
message: err.message,
...(err.errors && { errors: err.errors }),
});
}
res.status(500).json({
success: false,
message: isProduction ? 'Internal server error' : err.message,
});
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});Install dependencies:
npm install morgan cors helmet compression cookie-parserTesting the Upgraded API
# Health check (no auth needed)
curl http://localhost:3000/health
# Get todos (requires auth)
curl http://localhost:3000/api/todos \
-H "Authorization: Bearer demo-token"
# Create a todo
curl -X POST http://localhost:3000/api/todos \
-H "Authorization: Bearer demo-token" \
-H "Content-Type: application/json" \
-d '{"title":"Master middleware"}'
# Filter by completed
curl "http://localhost:3000/api/todos?completed=false" \
-H "Authorization: Bearer demo-token"
# Trigger 401
curl http://localhost:3000/api/todos
# Trigger 400 validation error
curl -X POST http://localhost:3000/api/todos \
-H "Authorization: Bearer demo-token" \
-H "Content-Type: application/json" \
-d '{}'
# Trigger 404
curl http://localhost:3000/api/todos/9999 \
-H "Authorization: Bearer demo-token"Middleware Order: The Golden Rules
Getting middleware order wrong is one of the most common Express mistakes. Here's the rule:
Why this order matters:
- Security headers should be set on every response, including error responses — so they go first
- Compression should wrap the full response — it goes near the top
- Body parsers must run before any route or middleware that reads
req.body - Logging should capture all requests, including those that hit auth or route errors
- Auth must run before routes that need user data
- Error handler must be last — it handles errors thrown by everything above it
Summary and Key Takeaways
What We Covered:
✅ Middleware is functions with (req, res, next) that form a processing pipeline
✅ Application-level middleware applies globally; router-level middleware is scoped
✅ Built-in middleware: express.json(), express.urlencoded(), express.static()
✅ Custom middleware for logging, auth, validation, and rate limiting
✅ Middleware factories return configured middleware — great for reusable, parameterized logic
✅ Third-party: morgan for logging, cors for CORS, helmet for security headers, compression for gzip
✅ Error handlers have 4 params (err, req, res, next) and must be registered last
✅ Use custom error classes to distinguish operational errors from bugs
✅ Wrap async handlers with asyncHandler or try/catch to avoid unhandled promise rejections
The Golden Rule: Middleware order matters. Security → Parsing → Logging → Auth → Routes → 404 → Error Handler.
Practice Exercises
-
Add morgan file logging — Write access logs to
logs/access.login Apache combined format, while keeping dev format in the console. -
IP-based rate limiter — Build a middleware factory that limits requests per IP with configurable window and max requests. Use a
Mapto track request timestamps. -
Request correlation — Add a
requestIdmiddleware that generates a UUID, attaches it toreqand adds it as a response header. Update the logger to include the request ID. -
Conditional CORS — Configure CORS to allow
localhost:3000andlocalhost:5173in development, and onlyhttps://myapp.comin production, based onNODE_ENV. -
Graceful error messages — Extend the error handler to catch Mongoose
ValidationErrorandCastError(or simulate them) and return clean 400/404 responses instead of exposing Mongoose internals.
What's Next?
In the next post, we'll tackle Routing and Request Handling in depth:
- Organizing routes with Express Router
- Nested and modular route structures
- Validating requests with
express-validator - Handling file uploads with
multer - RESTful API design best practices
The patterns we covered here — especially the error handling and middleware factories — will be used throughout the rest of the series.
Additional Resources
- Express.js Middleware Guide
- Express Error Handling
- morgan docs
- helmet docs
- cors docs
- compression docs
Next Steps in This Series:
- Post 3: Routing and Request Handling
- Post 4: Working with MongoDB and Mongoose
- Post 5: Working with PostgreSQL and Prisma
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.