middleware-patterns
Error handling middleware, validation, rate limiting, and security header patterns for Node.js APIs
$ npx -y skills add notque/vexjoy-agent --agent claude-codeHow 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.mddescription: 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 {
cRead more
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 {
cEssays and writing behind this toolkit live at vexjoy.com. AI agents skip steps. "Looks correct" replaces running tests. "Trivial change" replaces verification.
Repo: notque/vexjoy-agent
Other agents on vexjoy-agent.
- ansible-automation-engineer
Ansible automation: playbooks, roles, collections, Molecule testing, Vault security.
Open agent - modules
**Scope**: Module selection patterns, builtin vs command/shell decisions, collection modules, and version-specific module changes **Version range**: ansible-core 2.14+ / Ansible Collections (community.general 7.0+) **Generated**: 2026-04-04 — verify against current Ansible
Open agent - testing
**Scope**: Molecule test scenarios, ansible-lint rules, idempotency validation, and check-mode patterns **Version range**: Molecule 6.0+ / ansible-lint 6.0+ / ansible-core 2.14+ **Generated**: 2026-04-04 — verify against current Molecule and ansible-lint documentation
Open agent - base-instructions
Universal operational rules injected by /do at agent dispatch. Domain-specific rules live in each agent's .md file.
Open agent - communication-patterns
**Scope**: Failure modes in agent output style — over-reporting, self-congratulation, verbose narration, and hedging. Covers what to detect and how to fix each. **Version range**: all versions **Generated**: 2026-05-11
Open agent - combat-effects-upgrade
Zero-dependency combat visual upgrades: CSS particle replacement, Framer Motion combat juice, CSS 3D card transforms.
Open agent

