Skip to content

middleware-patterns

Error handling middleware, validation, rate limiting, and security header patterns for Node.js APIs

From plugin
vexjoy-agent
413198 skills198 agents10 commands86 hooks
Install
$ npx -y skills add notque/vexjoy-agent --agent claude-code

How it fires

How this agent gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.

Context preview

The summary Claude sees to decide when to auto-load this agent.

Error handling middleware, validation, rate limiting, and security header patterns for Node.js APIs

Agent definition

middleware-patterns.md
description: Error handling middleware, validation, rate limiting, and security header patterns for Node.js APIs

Express/Next.js Middleware Patterns

> **Scope**: Express 4.x/5.x and Next.js 14+ API route middleware — error handling, Zod validation, rate limiting, CORS, and security headers. > **Version range**: Express 4.18+, Next.js 14+, `zod` 3.22+, `express-rate-limit` 7.x > **Generated**: 2026-04-08

---

Overview

Middleware order is execution-critical. Error middleware (4-arg) must come last. Rate limiting before auth avoids leaking timing data. Validation before handlers prevents untrusted data processing.

---

Pattern Table

| Pattern | Version | Use When | Avoid When | |---------|---------|----------|------------| | Error middleware `(err, req, res, next)` | Express 4+ | All error handling | 3-arg function — not called by Express for errors | | `zod.safeParse()` | zod 3+ | Collecting all field errors | `zod.parse()` — throws on first error | | `express-rate-limit` with Redis store | express-rate-limit 7+ | Multi-process / multi-instance deployments | Single-process (memory store OK) | | `helmet()` | helmet 7+ | All Express apps | Manually setting individual security headers | | Next.js `middleware.ts` | Next.js 12+ | Edge-level auth/redirect | Heavy computation — runs on every request |

---

Correct Patterns

Centralized Error Handling Middleware

import { Request, Response, NextFunction } from 'express';

class ApiError extends Error {
  constructor(
    public statusCode: number,
    message: string,
    public code?: string
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

// MUST have exactly 4 parameters — Express identifies error middleware by arity
function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction // Required even if unused
): void {
  if (err instanceof ApiError) {
    res.status(err.statusCode).json({
      error: err.message,
      code: err.code,
      requestId: req.id, // From request ID middleware
    });
    return;
  }

  // Zod validation errors
  if (err.name === 'ZodError') {
    res.status(422).json({
      error: 'Validation failed',
      fields: JSON.parse(err.message),
    });
    return;
  }

  // Unknown errors — sanitize before sending
  console.error('[error]', err);
  res.status(500).json({
    error: 'Internal server error',
    requestId: req.id,
    // DO NOT include err.message or err.stack in production
  });
}

// Register LAST in middleware chain
app.use(errorHandler);

**Why**: Express identifies error middleware by arity. A 3-argument function is never called for errors.

---

Zod Request Validation Middleware

import { z, ZodSchema } from 'zod';
import { Request, Response, NextFunction } from 'express';

function validateBody<T>(schema: ZodSchema<T>) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      // Collect ALL field errors, not just the first
      const errors = result.error.issues.reduce<Record<string, string[]>>(
        (acc, issue) => {
          const field = issue.path.join('.');
          acc[field] = [...(acc[field] ?? []), issue.message];
          return acc;
        },
        {}
      );
      res.status(422).json({ error: 'Validation failed', fields: errors });
      return;
    }
    // Replace req.body with validated + type-safe data
    req.body = result.data;
    next();
  };
}

// Usage:
const CreateUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).max(128),
  name: z.string().min(1).max(100),
});

app.post('/users', validateBody(CreateUserSchema), async (req, res) => {
  // req.body is now typed as z.infer<typeof CreateUserSchema>
  const { email, password, name } = req.body;
  // ...
});

**Why**: `safeParse` collects all errors in one pass. `parse()` throws on first error only.

---

Rate Limiting with Redis Store

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });

// General API rate limit
const apiLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  limit: 100,
  standardHeaders: 'draft-7', // Sends RateLimit-* headers
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args: string[]) => redis.sendCommand(args),
  }),
  keyGenerator: (req) => req.user?.id ?? req.ip ?? 'anonymous',
  message: { error: 'Too many requests', retryAfter: 60 },
});

// Strict limit for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  limit: 5,
  store: new RedisStore({
    sendCommand: (...args: string[]) => redis.sendCommand(args),
  }),
  skipSuccessfulRequests: true, // Only count failed auth attempts
});

app.use('/api', apiLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/forgot-password', authLimiter);

**Why**: In-memory store doesn't work across multiple processes. Attackers bypass by distributing requests across pods. Redis store is shared.

---

Security Headers with Helmet

import helmet from 'helmet';
import cors from 'cors';

// Helmet sets: X-Frame-Options, X-Content-Type-Options, HSTS, CSP, etc.
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'nonce-{nonce}'"], // Replace nonce per request
      imgSrc: ["'self'", 'data:', 'https:'],
      connectSrc: ["'self'", process.env.API_URL!],
    },
  },
  hsts: {
    maxAge: 31536000, // 1 year
    includeSubDomains: true,
    preload: true,
  },
}));

// CORS: explicit allowlist, not '*'
app.use(cors({
  origin: (origin, callback) => {
    const allowList = (process.env.CORS_ORIGINS ?? '').split(',');
    if (!origin || allowList.includes(origin)) {
      callback(null, true);
    } else {
      c
Read more
Ships withvexjoy-agent

Essays and writing behind this toolkit live at vexjoy.com. AI agents skip steps. "Looks correct" replaces running tests. "Trivial change" replaces verification.

Get the whole plugin, auto-invoked