Understanding Express.js Middleware: From Basics to Advanced Patterns
Deep dive into Express.js middleware concepts with practical examples. Learn how to create, use, and chain middleware functions for better request handling and authentication.
Understanding Express.js Middleware: From Basics to Advanced Patterns
After building several backend APIs including the BKRS.io platform, I've learned that understanding middleware is crucial for creating scalable Express.js applications. Today, let's explore middleware concepts from the ground up.
What is Middleware?
Middleware functions are the backbone of Express.js applications. They have access to the request object (req), response object (res), and the next middleware function in the application's request-response cycle, commonly denoted by next.
Think of middleware as a series of functions that process requests before they reach your final route handlers.
Setting Up Basic Express Server
Let's start with a simple Express.js setup:
const express = require('express')
const app = express()
// Basic routes
app.get('/', (req, res) => {
res.send('Welcome to the main page!')
})
app.get('/users', (req, res) => {
res.send('Users page')
})
// Start server
app.listen(3000, () => {
console.log('Server running on port 3000')
})
This creates a basic server with two routes. But what if we want to log every request? That's where middleware comes in.
Creating Your First Middleware
Let's create a simple logging middleware:
function loggingMiddleware(req, res, next) {
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`)
next() // Important: Call next() to continue to the next middleware
}
Key Points About Middleware Functions:
- Three Parameters:
req,res,next - Must Call
next(): To pass control to the next middleware - Order Matters: Middleware executes in the order it's defined
Using Middleware Globally
To apply middleware to all routes, use app.use():
const express = require('express')
const app = express()
// Logging middleware function
function loggingMiddleware(req, res, next) {
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`)
next()
}
// Apply middleware globally
app.use(loggingMiddleware)
app.get('/', (req, res) => {
res.send('Welcome to the main page!')
})
app.get('/users', (req, res) => {
res.send('Users page')
})
app.listen(3000, () => {
console.log('Server running on port 3000')
})
Now every request will be logged before reaching the route handlers.
Route-Specific Middleware
You can also apply middleware to specific routes:
// Authorization middleware
function authorizeUsersAccess(req, res, next) {
if (req.query.role === 'admin') {
req.admin = true
next()
} else {
res.status(403).send('ERROR: Not Authorized')
}
}
// Apply middleware only to /users route
app.get('/users', authorizeUsersAccess, (req, res) => {
console.log('Admin status:', req.admin) // Will log: true
res.send('Welcome, Admin! You have access to users page.')
})
Testing the Authorization:
# This will work
curl "http://localhost:3000/users?role=admin"
# This will return 403 error
curl "http://localhost:3000/users?role=user"
Complete Example with Multiple Middleware
Here's a comprehensive example showing different middleware patterns:
const express = require('express')
const app = express()
// Global logging middleware
function loggingMiddleware(req, res, next) {
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`)
next()
}
// Authorization middleware
function authorizeUsersAccess(req, res, next) {
console.log('Checking authorization...')
if (req.query.role === 'admin') {
req.admin = true
console.log('User authorized as admin')
next()
} else {
console.log('Authorization failed')
res.status(403).send('ERROR: Not Authorized')
// Note: No next() call here - request ends
}
}
// Request timing middleware
function timingMiddleware(req, res, next) {
req.startTime = Date.now()
next()
}
// Apply global middleware
app.use(loggingMiddleware)
app.use(timingMiddleware)
// Routes
app.get('/', (req, res) => {
const duration = Date.now() - req.startTime
console.log(`Request processed in ${duration}ms`)
res.send('Welcome to the main page!')
})
// Route with specific middleware
app.get('/users', authorizeUsersAccess, (req, res) => {
const duration = Date.now() - req.startTime
console.log('Admin status:', req.admin)
console.log(`Admin request processed in ${duration}ms`)
res.json({
message: 'Welcome, Admin!',
admin: req.admin,
processingTime: `${duration}ms`
})
})
app.listen(3000, () => {
console.log('Server running on port 3000')
})
Middleware Execution Order
Understanding execution order is crucial:
// 1. Global middleware (executed first)
app.use(loggingMiddleware)
app.use(timingMiddleware)
// 2. Route-specific middleware (executed second)
app.get('/users', authorizeUsersAccess, (req, res) => {
// 3. Controller/Route handler (executed last)
res.send('Final response')
})
Execution Flow:
loggingMiddleware→ callsnext()timingMiddleware→ callsnext()authorizeUsersAccess→ callsnext()(if authorized)- Route handler → sends response
Important Middleware Concepts
1. Controller Actions vs Middleware
- Middleware: Functions that process requests and call
next() - Controllers: Final handlers that send responses (no
next()needed)
2. next() vs return
// ❌ Wrong - calling next() doesn't stop execution
function badMiddleware(req, res, next) {
if (!req.user) {
res.status(401).send('Unauthorized')
next() // This will cause errors!
}
// Code here still executes
}
// ✅ Correct - return after sending response
function goodMiddleware(req, res, next) {
if (!req.user) {
return res.status(401).send('Unauthorized')
}
next()
}
3. Error Handling Middleware
function errorHandler(err, req, res, next) {
console.error('Error:', err.message)
res.status(500).json({
error: 'Internal Server Error',
message: err.message
})
}
// Use error middleware (must be last)
app.use(errorHandler)
Real-World Applications
In my experience building BKRS.io, middleware proved essential for:
1. Authentication & Authorization
function authenticateToken(req, res, next) {
const token = req.headers['authorization']
if (!token) {
return res.status(401).json({ error: 'Access token required' })
}
// Verify JWT token
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Invalid token' })
req.user = user
next()
})
}
2. Request Validation
function validateCreatePost(req, res, next) {
const { title, content } = req.body
if (!title || !content) {
return res.status(400).json({
error: 'Title and content are required'
})
}
if (title.length > 100) {
return res.status(400).json({
error: 'Title must be less than 100 characters'
})
}
next()
}
3. Rate Limiting
const rateLimit = require('express-rate-limit')
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many requests, please try again later'
})
app.use('/api/', limiter)
Best Practices
1. Keep Middleware Focused
Each middleware should have a single responsibility:
// ✅ Good - single purpose
function logRequest(req, res, next) { /* logging only */ }
function authenticate(req, res, next) { /* auth only */ }
// ❌ Bad - multiple responsibilities
function logAndAuth(req, res, next) { /* logging + auth */ }
2. Handle Errors Properly
function asyncMiddleware(req, res, next) {
try {
// Async operations
someAsyncOperation()
next()
} catch (error) {
next(error) // Pass error to error handler
}
}
3. Use Built-in Middleware
// Body parsing
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
// Static files
app.use(express.static('public'))
// CORS
app.use(cors())
Custom vs Built-in Middleware
When building Express.js applications, you have two main options for middleware: creating custom middleware functions or using pre-built middleware modules.
Custom Middleware
As we've seen throughout this post, you can create your own middleware functions tailored to your specific needs:
// Custom authentication middleware
function authenticateUser(req, res, next) {
const token = req.headers.authorization
if (!token) {
return res.status(401).json({ error: 'No token provided' })
}
// Custom token verification logic
verifyToken(token, (err, user) => {
if (err) return res.status(403).json({ error: 'Invalid token' })
req.user = user
next()
})
}
// Custom request logging middleware
function customLogger(req, res, next) {
const timestamp = new Date().toISOString()
const method = req.method
const url = req.url
const userAgent = req.get('User-Agent')
console.log(`[${timestamp}] ${method} ${url} - ${userAgent}`)
next()
}
Express Official Middleware
Express.js provides a rich ecosystem of official middleware modules that handle common tasks. According to the Express.js middleware documentation, here are some essential ones:
Core Middleware Modules:
const express = require('express')
const morgan = require('morgan') // HTTP request logger
const helmet = require('helmet') // Security headers
const cors = require('cors') // Cross-origin requests
const compression = require('compression') // Response compression
const cookieParser = require('cookie-parser') // Cookie parsing
const app = express()
// Security middleware
app.use(helmet())
// Logging middleware
app.use(morgan('combined'))
// CORS middleware
app.use(cors({
origin: 'https://yourdomain.com',
credentials: true
}))
// Compression middleware
app.use(compression())
// Cookie parsing middleware
app.use(cookieParser())
// Body parsing middleware (built into Express 4.16+)
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
Popular Third-Party Middleware:
- helmet: Security headers for protecting your app
- passport: Authentication strategies (OAuth, JWT, etc.)
- multer: File upload handling
- express-rate-limit: Rate limiting
- express-validator: Input validation
When to Use Custom vs Built-in
Use Custom Middleware When:
- You need application-specific logic
- Existing middleware doesn't meet your exact requirements
- You want to learn how middleware works internally
- You need lightweight, optimized solutions
Use Built-in/Third-Party Middleware When:
- Common functionality (logging, security, parsing)
- Well-tested, production-ready solutions needed
- Time constraints (faster development)
- Community support and documentation important
Real-World Example: Combining Both Approaches
In my BKRS.io platform, I used a combination of both:
const app = express()
// Built-in middleware for common tasks
app.use(helmet()) // Security headers
app.use(morgan('combined')) // Request logging
app.use(cors({ origin: process.env.FRONTEND_URL }))
app.use(express.json({ limit: '10mb' }))
// Custom middleware for business logic
app.use(customRateLimiter) // Custom rate limiting logic
app.use(userActivityTracker) // Custom analytics
app.use(apiVersionHandler) // Custom API versioning
// Route-specific custom middleware
app.post('/api/posts',
authenticateUser, // Custom auth
validatePostData, // Custom validation
createPost // Controller
)
This approach gives you the best of both worlds: reliable, tested solutions for common tasks, and custom logic for your specific business requirements.
Conclusion
Middleware is the foundation of Express.js applications. Understanding how to create, use, and chain middleware functions is essential for building scalable backend services.
Key Takeaways:
- Middleware functions process requests in order
- Always call
next()unless sending a response - Use
returnwhen sending error responses - Keep middleware focused on single responsibilities
- Global middleware runs before route-specific middleware
The patterns shown here have been battle-tested in production applications serving thousands of users. Master these concepts, and you'll be well-equipped to build robust Express.js APIs.
Next Steps: Having explored Express.js middleware thoroughly, I'm curious to dive into how Go handles middleware patterns, particularly with the Fiber framework. The similarities and differences between Express.js and Go Fiber middleware could provide valuable insights for backend developers working across different languages.
References:
- Express.js Official Middleware Documentation
- Web Dev Simplified YouTube Video
- Web Dev Simplified Blog Post
This post is based on practical experience building production APIs and educational content from Web Dev Simplified. The examples shown are simplified for learning purposes but follow production-ready patterns.