Express’s two core concepts are routes (which URL + method combination does this handler respond to?) and middleware (what runs before/after the handler?). Understanding how middleware chains work unlocks Express’s full power.
How Middleware Works
Every Express handler is middleware — a function that receives (req, res, next). Calling next() passes control to the next middleware in the chain. Not calling next() (or calling res.send()) ends the chain:
// A logger that runs before every route
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`)
next() // pass control to the next middleware
})
// Auth check — short-circuits if not authenticated
app.use('/api', (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1]
if (!token) return res.status(401).json({ error: 'No token' })
req.user = verifyToken(token)
next()
})
// Route handler — ends the chain
app.get('/api/users', (req, res) => {
res.json({ users: [] })
})
Order matters: app.use registers middleware in sequence. The logger runs first, then the auth check, then route handlers. The error handler (covered below) always goes last.
Organizing Routes with Router
As your app grows, put related routes in their own file using express.Router(). Each router is its own mini middleware chain:
// routes/users.js
const router = require('express').Router()
// Middleware that only applies to this router
router.use((req, res, next) => {
console.log('User route accessed')
next()
})
// param middleware — runs when :id is in the URL
router.param('id', async (req, res, next, id) => {
const user = await User.findById(id)
if (!user) return res.status(404).json({ error: 'User not found' })
req.targetUser = user
next()
})
router.get('/', listUsers)
router.post('/', createUser)
router.get('/:id', getUser) // req.targetUser already set by param()
router.put('/:id', updateUser)
router.delete('/:id', deleteUser)
module.exports = router
// app.js
app.use('/api/users', require('./routes/users'))
app.use('/api/posts', require('./routes/posts'))
router.param is useful for loading resources by ID once and sharing them across multiple route handlers on that router.
Middleware Chains for Common Concerns
Rather than duplicating logic in handlers, build reusable middleware functions:
// middleware/auth.js
const jwt = require('jsonwebtoken')
const authenticate = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1]
if (!token) return res.status(401).json({ error: 'Authentication required' })
try {
req.user = jwt.verify(token, process.env.JWT_SECRET)
next()
} catch {
res.status(401).json({ error: 'Invalid or expired token' })
}
}
// Factory: returns middleware that checks for a specific role
const requireRole = (...roles) => (req, res, next) => {
if (!roles.includes(req.user?.role)) {
return res.status(403).json({ error: 'Insufficient permissions' })
}
next()
}
module.exports = { authenticate, requireRole }
// middleware/validate.js — validate request body against a schema
const { validationResult } = require('express-validator')
const validate = (req, res, next) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() })
}
next()
}
module.exports = validate
Chain them when you register routes:
const { authenticate, requireRole } = require('./middleware/auth')
const { body } = require('express-validator')
const validate = require('./middleware/validate')
// Public: no auth needed
router.post('/auth/login', loginHandler)
// User: authenticated
router.get('/profile', authenticate, getProfile)
// Admin: authenticated + admin role + validated body
router.post('/admin/users',
authenticate,
requireRole('admin'),
[body('email').isEmail(), body('name').notEmpty()],
validate,
createAdminUser
)
Reading this, you immediately know the security requirements for each route.
Async Error Handling
Express 4 doesn’t catch errors from async functions automatically. If an async handler throws, Express won’t catch it without a wrapper:
// ❌ Unhandled: if getUser() throws, Express never sees it
router.get('/:id', async (req, res) => {
const user = await getUser(req.params.id) // if this throws...
res.json(user) // ...Express hangs without an error response
})
// ✅ Wrap with asyncHandler
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next)
router.get('/:id', asyncHandler(async (req, res) => {
const user = await getUser(req.params.id)
if (!user) throw new AppError('User not found', 404)
res.json(user)
}))
Express 5 (currently in beta) handles async errors natively — no wrapper needed. For Express 4, wrap every async handler.
Error Handling Middleware
Error middleware takes four parameters (err, req, res, next) and must be registered last:
// Custom error class for application errors
class AppError extends Error {
constructor(message, statusCode) {
super(message)
this.statusCode = statusCode
}
}
// Error handler — goes after all routes
app.use((err, req, res, next) => {
// Log the full error for debugging (not visible to client)
console.error(err.stack)
const statusCode = err.statusCode || 500
const message = err.statusCode
? err.message // AppError — safe to expose
: 'Internal server error' // Unexpected error — hide details
res.status(statusCode).json({
error: message,
// Include stack trace in development only
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
})
})
// 404 handler — catches routes that don't exist
app.use((req, res) => {
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` })
})
A Structured App Layout
A production Express app typically looks like this:
src/
app.js # creates app, registers global middleware and routes
server.js # starts the HTTP server
routes/
users.js # user CRUD routes
auth.js # login/logout/register
middleware/
auth.js # authenticate, requireRole
validate.js # express-validator wrapper
errorHandler.js # error and 404 handlers
controllers/
userController.js # handler functions
services/
userService.js # business logic
models/
User.js # Mongoose schema or TypeORM entity
// app.js
const express = require('express')
const helmet = require('helmet') // security headers
const cors = require('cors')
const app = express()
// Global middleware — order matters
app.use(helmet()) // security headers first
app.use(cors({ origin: process.env.CORS_ORIGIN }))
app.use(express.json({ limit: '1mb' })) // parse bodies
app.use(requestLogger) // log all requests
// Routes
app.use('/api/auth', require('./routes/auth'))
app.use('/api/users', require('./routes/users'))
// Error handling — must be last
app.use(require('./middleware/errorHandler').notFound)
app.use(require('./middleware/errorHandler').errorHandler)
module.exports = app
Summary
- Middleware is just a function with
(req, res, next)— callingnext()continues the chain - Use
app.use(path, router)to modularize routes into separate files router.param('id', ...)loads a resource once for all routes using that parameter- Build middleware factories (
requireRole('admin')) to express security requirements inline when registering routes - Wrap async handlers with a catch-and-forward wrapper for Express 4 compatibility
- Error middleware takes four parameters; register it last; never expose raw error messages to clients
Comments