Understanding Express.js Middleware: From Basics to Advanced Patterns

Jay Kye
9 min read

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.

Express.jsNode.jsMiddlewareBackendJavaScript

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:

  1. loggingMiddleware → calls next()
  2. timingMiddleware → calls next()
  3. authorizeUsersAccess → calls next() (if authorized)
  4. 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:

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 return when 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:

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.