The power of Express.js comes from the middleware pattern. Every HTTP request travels through a pipeline of functions that run in sequence. This article covers how middleware works, the three most critical categories (auth, logging, error handling) and how to write your own, with production-ready examples.

What Is Middleware?

Middleware is a function with the signature (req, res, next) => {}. Calling next() advances the pipeline to the next middleware; not calling it leaves the request hanging. next(err) skips ahead to the error handler.

// Simplest possible middleware
app.use((req, res, next) => {
    req.startTime = Date.now();
    next();
});

app.get('/', (req, res) => {
    res.json({ took: Date.now() - req.startTime });
});

Request Logger

function requestLogger(req, res, next) {
    const start = Date.now();
    res.on('finish', () => {
        const dur = Date.now() - start;
        const level = res.statusCode >= 500 ? 'ERROR' : res.statusCode >= 400 ? 'WARN' : 'INFO';
        console.log(`[${level}] ${req.method} ${req.originalUrl} ${res.statusCode} ${dur}ms`);
    });
    next();
}

app.use(requestLogger);

Authentication Middleware

const jwt = require('jsonwebtoken');

async function requireAuth(req, res, next) {
    const header = req.headers.authorization || '';
    const token = header.startsWith('Bearer ') ? header.slice(7) : null;
    if (!token) return res.status(401).json({ error: 'Token required' });

    try {
        const payload = jwt.verify(token, process.env.JWT_SECRET, {
            algorithms: ['HS256']
        });
        const user = await db.users.findByPk(payload.sub);
        if (!user || !user.active) {
            return res.status(401).json({ error: 'Invalid user' });
        }
        req.user = user;
        next();
    } catch (err) {
        return res.status(401).json({ error: 'Invalid token' });
    }
}

// Usage
app.get('/api/me', requireAuth, (req, res) => {
    res.json(req.user);
});

Role-Based Authorization

function requireRole(...allowedRoles) {
    return (req, res, next) => {
        if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
        if (!allowedRoles.includes(req.user.role)) {
            return res.status(403).json({ error: 'Insufficient permission' });
        }
        next();
    };
}

// Chained use
app.delete('/api/users/:id', requireAuth, requireRole('admin'), async (req, res) => {
    await db.users.destroy({ where: { id: req.params.id } });
    res.status(204).end();
});

Error Handler

An Express error handler has the 4-argument signature (err, req, res, next) and must always be the last middleware in the pipeline.

class AppError extends Error {
    constructor(status, message, code) {
        super(message);
        this.status = status;
        this.code = code;
    }
}

// Usage
if (!user) throw new AppError(404, 'User not found', 'USER_NOT_FOUND');

// Async route handler wrapper
function asyncHandler(fn) {
    return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}

app.get('/api/users/:id', asyncHandler(async (req, res) => {
    const user = await db.users.findByPk(req.params.id);
    if (!user) throw new AppError(404, 'User not found', 'USER_NOT_FOUND');
    res.json(user);
}));

// Global error handler
app.use((err, req, res, next) => {
    const status = err.status || 500;
    const reqId = req.id || Date.now().toString(36);
    if (status >= 500) {
        console.error(`[${reqId}] ${err.stack}`);
    }
    res.status(status).json({
        error: err.message || 'Internal server error',
        code: err.code,
        requestId: reqId
    });
});

Validation Middleware

const { z } = require('zod');

function validate(schema) {
    return (req, res, next) => {
        const result = schema.safeParse(req.body);
        if (!result.success) {
            return res.status(400).json({
                error: 'Validation failed',
                issues: result.error.flatten()
            });
        }
        req.body = result.data;  // parsed + trimmed
        next();
    };
}

const createUserSchema = z.object({
    email: z.string().email(),
    password: z.string().min(8),
    name: z.string().min(2).max(100)
});

app.post('/api/users', validate(createUserSchema), async (req, res) => {
    // req.body is validated and typed here
    const user = await db.users.create(req.body);
    res.status(201).json(user);
});

Rate Limit

const rateLimit = require('express-rate-limit');

// Global
app.use(rateLimit({ windowMs: 60000, max: 120 }));

// Sensitive endpoint
app.use('/login', rateLimit({
    windowMs: 900000, max: 5,
    message: { error: 'Too many login attempts' }
}));

Pipeline Order

Middleware order matters:

  • Security (helmet, cors) — first
  • Parser (express.json) — you cannot authenticate what you cannot read
  • Logger — early so failed requests are logged too
  • Rate limit — before authentication
  • Authentication — before routes
  • Routes
  • 404 handler — after all routes
  • Error handler — last

Conclusion

The middleware pattern is what makes Express both easy to learn and infinitely extensible. Keep each middleware focused on a single responsibility, composable and testable. With that discipline, even large Express apps stay tidy in three or four folders.

Node.js backend development

API design with Express/Fastify, auth architecture and production deploys Write to us

WhatsApp