DEV Community

Alex Chen
Alex Chen

Posted on

5 Express.js Middleware Patterns You'll Use in Every App

5 Express.js Middleware Patterns You'll Use in Every App

Middleware is Express's superpower. These 5 patterns cover 90% of what you need.

What Is Middleware?

Request → [Middleware 1] → [Middleware 2] → [Middleware 3] → Response
            ↓                ↓                ↓
          Log auth        Parse body       Handle error

Each middleware can:
- Modify request (req) or response (res)
- End the response (res.send())
- Pass control to next middleware (next())
- Throw an error (next(err))
Enter fullscreen mode Exit fullscreen mode

Pattern 1: Request Validation Middleware

// ❌ Validation scattered in every route:
app.post('/api/users', async (req, res) => {
  if (!req.body.email) return res.status(400).json({ error: 'email required' });
  if (!isValidEmail(req.body.email)) return res.status(400).json({ error: 'invalid email' });
  if (!req.body.name || req.body.name.length > 100) return res.status(400).json({ error: 'invalid name' });
  // ... actual logic buried under validation noise
});

// ✅ Reusable validation middleware:
function validate(schema) {
  return (req, res, next) => {
    const { error, value } = schema.validate(req.body, {
      abortEarly: false, // Return ALL errors, not just first
      stripUnknown: true, // Remove extra fields
    });

    if (error) {
      const details = error.details.map(e => ({
        field: e.path.join('.'),
        issue: e.message,
      }));
      return res.status(422).json({
        error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details }
      });
    }

    req.body = value; // Use sanitized values
    next();
  };
}

// Usage:
const Joi = require('joi');

const createUserSchema = Joi.object({
  email: Joi.string().email().required(),
  name: Joi.string().min(1).max(100).required(),
  role: Joi.string().valid('user', 'admin').default('user'),
});

app.post('/api/users', validate(createUserSchema), async (req, res) => {
  // req.body is already validated and sanitized!
  const user = await User.create(req.body);
  res.status(201).json({ data: user });
});
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Authentication Middleware

// JWT authentication middleware
function auth(options = {}) {
  const { required = true, roles = [] } = options;

  return async (req, res, next) => {
    const header = req.headers.authorization;

    if (!header?.startsWith('Bearer ')) {
      if (required) return res.status(401).json({ error: 'Missing token' });
      return next(); // Optional auth — proceed as anonymous
    }

    try {
      const token = header.split(' ')[1];
      const payload = jwt.verify(token, process.env.JWT_SECRET);

      req.user = payload; // Attach user to request

      // Role check
      if (roles.length && !roles.includes(payload.role)) {
        return res.status(403).json({ error: 'Insufficient permissions' });
      }

      next();
    } catch (err) {
      if (required) {
        return res.status(401).json({ error: 'Invalid or expired token' });
      }
      next(); // Invalid token but auth not required
    }
  };
}

// Usage variations:

// Require authentication
app.get('/api/profile', auth(), (req, res) => {
  res.json({ data: req.user });
});

// Optional authentication (works for logged-in and guest users)
app.get('/api/products', auth({ required: false }), (req, res) => {
  const prices = req.user ? getDiscountPrices() : getRegularPrices();
  res.json({ data: prices });
});

// Role-based access
app.delete('/api/users/:id', auth({ roles: ['admin'] }), async (req, res) => {
  await User.delete(req.params.id);
  res.json({ success: true });
});
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Rate Limiting with Context

// Simple in-memory rate limiter (for single-server apps)
function rateLimit(options = {}) {
  const {
    windowMs = 60_000,     // 1 minute window
    max = 100,             // Max requests per window
    keyGenerator = (req) => req.ip,
    message = { error: 'Too many requests' },
  } = options;

  const requests = new Map();

  return (req, res, next) => {
    const key = keyGenerator(req);
    const now = Date.now();

    // Clean old entries
    if (requests.has(key)) {
      const entry = requests.get(key);
      if (now - entry.resetTime > windowMs) {
        requests.delete(key); // Window expired
      }
    }

    if (!requests.has(key)) {
      requests.set(key, { count: 1, resetTime: now + windowMs });
    } else {
      const entry = requests.get(key);
      entry.count++;

      if (entry.count > max) {
        const retryAfter = Math.ceil((entry.resetTime - now) / 1000);
        res.set('Retry-After', String(retryAfter));
        res.status(429).json(message);
        return;
      }
    }

    // Add rate limit headers
    const entry = requests.get(key);
    res.set('X-RateLimit-Limit', String(max));
    res.set('X-RateLimit-Remaining', String(Math.max(0, max - entry.count)));
    res.set('X-RateLimit-Reset', new Date(entry.resetTime).toISOString());

    next();
  };
}

// Usage:
// Global rate limiter
app.use(rateLimit({ max: 1000 }));

// Stricter for auth endpoints
app.post('/api/login', 
  rateLimit({ max: 5, windowMs: 15 * 60_000 }), // 5 per 15 min
  handleLogin
);

// Per-user rate limiter
app.post('/api/invite',
  rateLimit({ max: 3, windowMs: 3600_000, keyGenerator: (req) => req.user.id }),
  sendInvite
);
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Async Error Handler

// The #1 Express bug source: unhandled promise rejections
// This wrapper fixes it forever:

function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

// Now you can use async/await in routes WITHOUT try/catch everywhere:
app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError('User', req.params.id); // Goes to error handler!
  res.json({ data: user });
}));

// Combined with the global error handler from earlier:
app.use((err, req, res, _next) => {
  console.error(`[ERR] ${req.method} ${req.path}:`, err.message);

  const statusCode = err.statusCode || err.status || 500;
  res.status(statusCode).json({
    error: {
      code: err.code || 'INTERNAL_ERROR',
      message: statusCode === 500 && process.env.NODE_ENV === 'production'
        ? 'Internal server error'
        : err.message,
    },
  });
});
Enter fullscreen mode Exit fullscreen mode

Pattern 5: Request Logging & Correlation

// Add unique ID to every request for tracing
function requestId(req, _res, next) {
  req.id = crypto.randomUUID();
  next();
}

// Structured logging middleware
function requestLogger(req, res, next) {
  const start = Date.now();

  res.on('finish', () => {
    const duration = Date.now() - start;
    const logData = {
      id: req.id,
      method: req.method,
      url: req.originalUrl,
      status: res.statusCode,
      duration: `${duration}ms`,
      ip: req.ip,
      userAgent: req.headers['user-agent'],
    };

    if (res.statusCode >= 400) {
      console.error(JSON.stringify(logData)); // Errors to stderr
    } else {
      console.log(JSON.stringify(logData));   // Normal to stdout
    }
  });

  next();
}

// Apply to all routes
app.use(requestId);
app.use(requestLogger);

// Now every log line has a request ID for correlation:
// {"id":"abc123","method":"GET","url":"/api/users","status":200,"duration":"12ms","ip":"1.2.3.4"}
// When you see an error in logs, search for the ID to find the full request flow
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

const express = require('express');
const app = express();

// Order matters! Middleware runs in sequence.

// 1. Core
app.use(express.json({ limit: '10kb' }));
app.use(requestId());
app.use(requestLogger());

// 2. Security
app.use(rateLimit({ max: 1000 }));
app.use(helmet());

// 3. Routes
app.post('/api/users',
  rateLimit({ max: 30, windowMs: 60_000 }),
  validate(createUserSchema),
  asyncHandler(async (req, res) => {
    const user = await User.create(req.body);
    res.status(201).json({ data: user });
  })
);

app.get('/api/profile',
  auth(),
  asyncHandler(async (req, res) => {
    res.json({ data: req.user });
  })
);

app.delete('/api/users/:id',
  auth({ roles: ['admin'] }),
  asyncHandler(async (req, res) => {
    await User.delete(req.params.id);
    res.json({ success: true });
  })
);

// 4. Error handler (MUST be last)
app.use((err, req, res, _next) => {
  const statusCode = err.statusCode || 500;
  res.status(statusCode).json({
    error: {
      code: err.code || 'INTERNAL_ERROR',
      message: statusCode === 500 && process.env.NODE_ENV === 'production'
        ? 'Internal server error'
        : err.message,
      requestId: req.id,
    },
  });
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Quick Reference Card

Pattern Purpose Package Needed
validate() Input sanitization joi / zod
auth() Authentication jsonwebtoken
rateLimit() Throttling None (custom) or express-rate-limit
asyncHandler() Error wrapping None
requestId() Request tracing crypto (built-in)

What's your favorite Express middleware pattern? Share it below!

Follow @armorbreak for more Node.js content.

Top comments (0)