Episode 3 — NodeJS MongoDB Backend Architecture / 3.6 — Middleware in Express

3.6.e — Custom Middleware Patterns

In one sentence: Custom middleware lets you encapsulate reusable logic -- logging, authentication, validation, timing, and more -- into composable functions, and patterns like middleware factories, async handlers, and res.locals make them flexible enough for any production Express application.

Navigation: <- 3.6.d Router-Level Middleware | 3.6.f -- Error Handling and Security Middleware ->


1. Writing a Request Logger Middleware

A logger records every request for debugging and monitoring.

// middleware/logger.js

const logger = (req, res, next) => {
  const start = Date.now();
  const timestamp = new Date().toISOString();

  // Log when request comes in
  console.log(`--> [${timestamp}] ${req.method} ${req.originalUrl}`);
  console.log(`    IP: ${req.ip} | User-Agent: ${req.get('User-Agent')}`);

  // Log when response is sent
  res.on('finish', () => {
    const duration = Date.now() - start;
    const statusColor = res.statusCode >= 400 ? '\x1b[31m' : '\x1b[32m';
    const reset = '\x1b[0m';

    console.log(
      `<-- [${timestamp}] ${req.method} ${req.originalUrl} ` +
      `${statusColor}${res.statusCode}${reset} (${duration}ms)`
    );
  });

  next();
};

module.exports = logger;

Usage:

const logger = require('./middleware/logger');

app.use(logger);

app.get('/api/users', (req, res) => {
  res.json({ users: [] });
});

Console output:

--> [2025-10-15T14:30:00.000Z] GET /api/users
    IP: ::1 | User-Agent: Mozilla/5.0...
<-- [2025-10-15T14:30:00.000Z] GET /api/users 200 (5ms)

2. Writing an Authentication Middleware

Authentication middleware verifies credentials and attaches user data to the request.

// middleware/auth.js
const jwt = require('jsonwebtoken');

const authenticate = (req, res, next) => {
  // 1. Extract token from Authorization header
  const authHeader = req.headers.authorization;

  if (!authHeader) {
    return res.status(401).json({
      error: 'Authentication required',
      message: 'No Authorization header found'
    });
  }

  if (!authHeader.startsWith('Bearer ')) {
    return res.status(401).json({
      error: 'Invalid format',
      message: 'Use: Bearer <token>'
    });
  }

  const token = authHeader.split(' ')[1];

  // 2. Verify token
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // 3. Attach user to request for downstream handlers
    req.user = {
      id: decoded.id,
      email: decoded.email,
      role: decoded.role
    };

    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    if (err.name === 'JsonWebTokenError') {
      return res.status(401).json({ error: 'Invalid token' });
    }
    return res.status(500).json({ error: 'Authentication failed' });
  }
};

module.exports = authenticate;

Usage:

const authenticate = require('./middleware/auth');

// Protected route -- authenticate runs first
app.get('/api/profile', authenticate, (req, res) => {
  // req.user is available because authenticate set it
  res.json({ user: req.user });
});

// Or protect an entire router
router.use(authenticate);
router.get('/settings', (req, res) => {
  res.json({ userId: req.user.id, settings: {} });
});

3. Writing a Validation Middleware

Validation middleware checks that request data meets requirements before the route handler runs.

// middleware/validate.js

// Simple field presence check
const requireFields = (...fields) => {
  return (req, res, next) => {
    const missing = fields.filter(field => {
      const value = req.body[field];
      return value === undefined || value === null || value === '';
    });

    if (missing.length > 0) {
      return res.status(400).json({
        error: 'Validation failed',
        missing: missing,
        message: `Required fields: ${missing.join(', ')}`
      });
    }

    next();
  };
};

// Type checking
const validateTypes = (schema) => {
  return (req, res, next) => {
    const errors = [];

    for (const [field, expectedType] of Object.entries(schema)) {
      const value = req.body[field];

      if (value !== undefined && typeof value !== expectedType) {
        errors.push(`${field} must be ${expectedType}, got ${typeof value}`);
      }
    }

    if (errors.length > 0) {
      return res.status(400).json({
        error: 'Type validation failed',
        details: errors
      });
    }

    next();
  };
};

// Email format check
const validateEmail = (field = 'email') => {
  return (req, res, next) => {
    const email = req.body[field];
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

    if (email && !emailRegex.test(email)) {
      return res.status(400).json({
        error: 'Invalid email format',
        field: field
      });
    }

    next();
  };
};

module.exports = { requireFields, validateTypes, validateEmail };

Usage:

const { requireFields, validateTypes, validateEmail } = require('./middleware/validate');

app.post('/api/users',
  requireFields('name', 'email', 'password'),
  validateTypes({ name: 'string', email: 'string' }),
  validateEmail('email'),
  (req, res) => {
    // If we get here, all validations passed
    res.status(201).json({ message: 'User created', user: req.body });
  }
);

app.post('/api/products',
  requireFields('name', 'price'),
  validateTypes({ name: 'string', price: 'number' }),
  (req, res) => {
    res.status(201).json({ product: req.body });
  }
);

4. Middleware Factories -- Functions That Return Middleware

A middleware factory is a function that accepts configuration and returns a middleware function. This makes middleware reusable with different settings.

// Pattern: factory function returns middleware
const middlewareFactory = (options) => {
  return (req, res, next) => {
    // Use options to customize behavior
    next();
  };
};

Example 1: Role-based access control

// middleware/authorize.js
const requireRole = (...allowedRoles) => {
  return (req, res, next) => {
    // req.user was set by authenticate middleware
    if (!req.user) {
      return res.status(401).json({ error: 'Not authenticated' });
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        error: 'Forbidden',
        message: `Requires role: ${allowedRoles.join(' or ')}`,
        yourRole: req.user.role
      });
    }

    next();
  };
};

module.exports = requireRole;
const authenticate = require('./middleware/auth');
const requireRole = require('./middleware/authorize');

// Anyone authenticated can view
app.get('/api/users', authenticate, (req, res) => {
  res.json({ users: [] });
});

// Only moderators and admins can edit
app.put('/api/users/:id',
  authenticate,
  requireRole('moderator', 'admin'),
  (req, res) => {
    res.json({ message: 'User updated' });
  }
);

// Only admins can delete
app.delete('/api/users/:id',
  authenticate,
  requireRole('admin'),
  (req, res) => {
    res.json({ message: 'User deleted' });
  }
);

Example 2: Configurable rate message

const withRateMessage = (resource) => {
  return (req, res, next) => {
    // Imagine rate-check logic here
    const remaining = 10; // Would come from a store
    if (remaining <= 0) {
      return res.status(429).json({
        error: `Too many requests for ${resource}`,
        retryAfter: 60
      });
    }
    res.setHeader('X-RateLimit-Remaining', remaining);
    next();
  };
};

app.get('/api/search', withRateMessage('search'), searchHandler);
app.post('/api/upload', withRateMessage('upload'), uploadHandler);

Example 3: Configurable cache headers

const cacheControl = (maxAge = 0) => {
  return (req, res, next) => {
    if (maxAge > 0) {
      res.setHeader('Cache-Control', `public, max-age=${maxAge}`);
    } else {
      res.setHeader('Cache-Control', 'no-store');
    }
    next();
  };
};

// Static data -- cache for 1 hour
app.get('/api/config', cacheControl(3600), getConfig);

// Dynamic data -- no cache
app.get('/api/notifications', cacheControl(0), getNotifications);

// Product listings -- cache for 5 minutes
app.get('/api/products', cacheControl(300), getProducts);

5. Async Middleware -- Handling Promises

Many middleware functions need to do async work (database queries, API calls). You must handle errors properly to avoid unhandled promise rejections.

The problem with raw async middleware

// DANGEROUS -- unhandled rejections crash the server!
app.use(async (req, res, next) => {
  const user = await User.findById(req.userId); // If this throws...
  req.user = user;                               // ...next() is never called
  next();                                        // ...error is unhandled
});

Solution 1: try-catch in every async middleware

app.use(async (req, res, next) => {
  try {
    const user = await User.findById(req.userId);
    req.user = user;
    next();
  } catch (err) {
    next(err); // Pass error to error-handling middleware
  }
});

Solution 2: async wrapper utility (recommended)

Write a helper function that wraps any async middleware with error handling.

// middleware/asyncHandler.js

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

module.exports = asyncHandler;

Usage -- clean and safe:

const asyncHandler = require('./middleware/asyncHandler');

// No try-catch needed -- errors automatically go to error middleware
app.get('/api/users', asyncHandler(async (req, res) => {
  const users = await User.find();
  res.json({ users });
}));

app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.json({ user });
}));

app.post('/api/users', asyncHandler(async (req, res) => {
  const user = await User.create(req.body);
  res.status(201).json({ user });
}));

Async authentication middleware with the wrapper

const authenticate = asyncHandler(async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'Token required' });
  }

  const decoded = jwt.verify(token, process.env.JWT_SECRET);
  const user = await User.findById(decoded.id);

  if (!user) {
    return res.status(401).json({ error: 'User no longer exists' });
  }

  req.user = user;
  next();
});

6. Chaining Multiple Custom Middleware on a Single Route

Express allows you to stack as many middleware functions as needed on a single route.

const authenticate = require('./middleware/auth');
const requireRole = require('./middleware/authorize');
const { requireFields, validateEmail } = require('./middleware/validate');
const asyncHandler = require('./middleware/asyncHandler');

// POST /api/admin/users -- create a user (admin only)
app.post('/api/admin/users',
  authenticate,                               // 1. Verify JWT
  requireRole('admin'),                       // 2. Check admin role
  requireFields('name', 'email', 'role'),     // 3. Validate required fields
  validateEmail('email'),                     // 4. Validate email format
  asyncHandler(async (req, res) => {          // 5. Create user
    const user = await User.create(req.body);
    res.status(201).json({ user });
  })
);

Execution flow:

Request arrives
   |
   v
authenticate  --> Is there a valid JWT?
   |               NO --> 401 Unauthorized (stops here)
   | YES
   v
requireRole('admin')  --> Is user.role === 'admin'?
   |                       NO --> 403 Forbidden (stops here)
   | YES
   v
requireFields(...)  --> Are name, email, role present?
   |                     NO --> 400 Bad Request (stops here)
   | YES
   v
validateEmail(...)  --> Is email format valid?
   |                     NO --> 400 Bad Request (stops here)
   | YES
   v
Route handler  --> Create user, respond 201

7. res.locals -- Sharing Data Between Middleware

res.locals is an object that lives for the lifetime of the request. Any middleware can write to it, and the route handler (or later middleware) can read from it. It is the official way to pass data through the middleware chain.

// Middleware 1: Get user from token
app.use(asyncHandler(async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (token) {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    res.locals.user = await User.findById(decoded.id);
  }
  next();
}));

// Middleware 2: Get user's permissions
app.use(asyncHandler(async (req, res, next) => {
  if (res.locals.user) {
    res.locals.permissions = await Permission.findByRole(res.locals.user.role);
  }
  next();
}));

// Route handler: Use both
app.get('/api/dashboard', (req, res) => {
  if (!res.locals.user) {
    return res.status(401).json({ error: 'Not authenticated' });
  }

  res.json({
    user: res.locals.user,
    permissions: res.locals.permissions,
    canDelete: res.locals.permissions?.includes('delete')
  });
});

res.locals vs req.customProperty

ApproachConventionWhen to Use
req.userCommon for user infoWidely accepted for auth data
res.localsOfficial Express patternSharing processed data between middleware
req.customPropAd-hocQuick additions (e.g., req.startTime)

Best practice: Use req.user for authentication (it is nearly universal). Use res.locals for everything else you need to share through the chain.

// Common convention
req.user = decodedUser;          // Auth data
res.locals.permissions = perms;  // Processed data
res.locals.pagination = {        // Computed data
  page: 1,
  limit: 20,
  total: 100
};

8. Real-World Pattern: Request Timing

// middleware/timing.js
const timing = (req, res, next) => {
  const start = process.hrtime.bigint();

  res.on('finish', () => {
    const end = process.hrtime.bigint();
    const durationNs = end - start;
    const durationMs = Number(durationNs) / 1e6;

    // Set a custom header
    // Note: headers are already sent by the time 'finish' fires,
    // so we log instead
    console.log(
      `[Timing] ${req.method} ${req.originalUrl} ` +
      `${res.statusCode} ${durationMs.toFixed(2)}ms`
    );
  });

  // You CAN set the header before the response is sent:
  const originalJson = res.json.bind(res);
  res.json = (body) => {
    const end = process.hrtime.bigint();
    const durationMs = Number(end - start) / 1e6;
    res.setHeader('X-Response-Time', `${durationMs.toFixed(2)}ms`);
    return originalJson(body);
  };

  next();
};

module.exports = timing;

9. Real-World Pattern: API Versioning

// middleware/apiVersion.js
const apiVersion = (defaultVersion = '1') => {
  return (req, res, next) => {
    // Check for version in header, query, or URL
    const version =
      req.headers['x-api-version'] ||
      req.headers['accept-version'] ||
      req.query.v ||
      defaultVersion;

    req.apiVersion = version;
    res.setHeader('X-API-Version', version);

    next();
  };
};

module.exports = apiVersion;
const apiVersion = require('./middleware/apiVersion');

app.use(apiVersion('2'));

app.get('/api/users', (req, res) => {
  if (req.apiVersion === '1') {
    // V1: simple array
    return res.json([{ id: 1, name: 'Alice' }]);
  }

  // V2: wrapped response
  res.json({
    data: [{ id: 1, name: 'Alice' }],
    meta: { version: req.apiVersion, count: 1 }
  });
});

10. Real-World Pattern: Request Sanitization

// middleware/sanitize.js
const sanitize = (req, res, next) => {
  // Trim all string values in body
  if (req.body && typeof req.body === 'object') {
    for (const key of Object.keys(req.body)) {
      if (typeof req.body[key] === 'string') {
        req.body[key] = req.body[key].trim();
      }
    }
  }

  // Trim query parameters
  for (const key of Object.keys(req.query)) {
    if (typeof req.query[key] === 'string') {
      req.query[key] = req.query[key].trim();
    }
  }

  next();
};

module.exports = sanitize;
app.use(express.json());
app.use(sanitize);

app.post('/api/users', (req, res) => {
  // req.body.name is already trimmed
  console.log(req.body.name); // "Alice" instead of "  Alice  "
  res.json({ user: req.body });
});

11. Middleware Organization -- File Structure

middleware/
  asyncHandler.js    -- Wraps async functions for error handling
  auth.js            -- authenticate, requireRole
  validate.js        -- requireFields, validateTypes, validateEmail
  logger.js          -- Request logger
  timing.js          -- Response time tracking
  sanitize.js        -- Input trimming and cleanup
  apiVersion.js      -- API versioning
  errorHandler.js    -- Central error handler (see 3.6.f)

Each file exports one or more middleware functions:

// middleware/index.js -- Optional barrel export
module.exports = {
  asyncHandler: require('./asyncHandler'),
  authenticate: require('./auth').authenticate,
  requireRole: require('./auth').requireRole,
  logger: require('./logger'),
  timing: require('./timing'),
  sanitize: require('./sanitize'),
  ...require('./validate')
};
// Clean imports in server.js
const {
  asyncHandler,
  authenticate,
  requireRole,
  logger,
  sanitize
} = require('./middleware');

Key Takeaways

  1. Custom middleware encapsulates reusable request-processing logic (logging, auth, validation, timing).
  2. Middleware factories (functions returning middleware) make middleware configurable: requireRole('admin'), cacheControl(3600).
  3. Async middleware must handle errors -- use try/catch or the asyncHandler wrapper to forward errors to Express error handling.
  4. Chain multiple middleware on a single route for a clean separation of concerns: auth -> role check -> validation -> handler.
  5. res.locals is the official way to share data between middleware and route handlers within a single request.
  6. Keep middleware in a dedicated middleware/ directory, one concern per file.

Explain-It Challenge

Explain without notes:

  1. What is a middleware factory? Write a pseudo-code example of one.
  2. Why is asyncHandler useful? What problem does it solve that plain async (req, res, next) does not?
  3. When would you use res.locals instead of directly attaching a property to req?
  4. A POST endpoint needs to: verify JWT, check admin role, validate 3 required fields, and create a database record. Describe the middleware chain.

Navigation: <- 3.6.d Router-Level Middleware | 3.6.f -- Error Handling and Security Middleware ->