DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for Express.js: 13 Rules That Stop AI from Breaking Your Middleware Chain

If you've worked with Express.js for more than a week, you know the feeling: you ask Claude to add a route, or refactor some middleware, and it hands back code that looks fine — until you run it. The headers are already sent. The error handler has the wrong signature. The async route swallows rejections silently. The middleware mutates req after calling next().

None of these are hard bugs to write. They're easy bugs to write if you don't know Express's specific conventions. And Claude doesn't know your version of Express, your middleware stack, or your error handling contract — unless you tell it.

That's what a CLAUDE.md file is for.

Here are 13 rules that stop the most common AI-generated Express.js mistakes before they reach your codebase.


Rule 1: Declare your Express version and Node version explicitly

## Stack
- Express: 4.19.2 (NOT 5.x — async error propagation differs)
- Node: 20.12 LTS
- TypeScript: 5.4 (strict mode enabled)
Enter fullscreen mode Exit fullscreen mode

Express 4 and Express 5 handle async errors completely differently. Express 5 natively catches Promise rejections in route handlers. Express 4 does not. If Claude generates Express 5-style async routes for your Express 4 app, they'll silently swallow errors in production.

Lock the version. Make it the first thing in your CLAUDE.md.


Rule 2: Async routes require explicit error handling in Express 4

## Async Routes (Express 4)
All async route handlers MUST use the asyncHandler wrapper or explicit try/catch.
Express 4 does NOT catch unhandled Promise rejections in routes.

// CORRECT
router.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await getUser(req.params.id);
  res.json(user);
}));

// WRONG — unhandled rejection in Express 4
router.get('/users/:id', async (req, res) => {
  const user = await getUser(req.params.id); // throws → silent crash
  res.json(user);
});
Enter fullscreen mode Exit fullscreen mode

This is the single most common Express.js AI mistake. Claude will generate clean-looking async routes that crash silently on error. Spell out the rule explicitly.


Rule 3: Error middleware always takes four arguments

## Error Handlers
Error-handling middleware MUST have exactly 4 parameters: (err, req, res, next).
Express detects error middleware by arity. 3-param functions are NOT called on error.

// CORRECT
app.use((err, req, res, next) => {
  res.status(err.status || 500).json({ error: err.message });
});

// WRONG — Express treats this as regular middleware
app.use((err, res, next) => { ... }); // 3 params = not an error handler
Enter fullscreen mode Exit fullscreen mode

Express uses function.length to decide whether middleware is an error handler. Get the signature wrong and your error handling silently doesn't work. Claude gets this wrong often, especially when TypeScript types are involved.


Rule 4: Never mutate req or res after calling next()

## Middleware Contract
After calling next(), do NOT read from or write to req or res.
The request may have moved to another middleware or already sent a response.

// CORRECT
function logRequest(req, res, next) {
  const start = Date.now();
  next();
  // do NOT access req.body or res.statusCode here
}

// WRONG
function addHeader(req, res, next) {
  next();
  res.setHeader('X-Custom', 'value'); // may throw if response already sent
}
Enter fullscreen mode Exit fullscreen mode

AI-generated middleware often tries to do post-processing after next(). In synchronous middleware this can trigger "Cannot set headers after they are sent" errors that are notoriously hard to trace.


Rule 5: Validate all request input with a schema library

## Input Validation
ALL route handlers MUST validate request input using zod before any business logic.
Do not use manual checks (if (!req.body.email)) — use schema validation.

import { z } from 'zod';

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

router.post('/users', asyncHandler(async (req, res) => {
  const body = CreateUserSchema.parse(req.body); // throws ZodError on invalid input
  // body is now fully typed and validated
}));
Enter fullscreen mode Exit fullscreen mode

Without this rule, Claude generates ad-hoc validation scattered across handlers. Specify the library (zod, joi, yup) — each has different APIs and Claude will mix them.


Rule 6: Use router-level middleware, not app-level, for feature isolation

## Router Architecture
Feature-specific middleware goes on the feature router, NOT on app.
app.use() middleware applies to ALL routes — use it only for truly global concerns
(body parsing, security headers, request logging).

// CORRECT
const usersRouter = express.Router();
usersRouter.use(requireAuth); // auth only for /users routes
app.use('/users', usersRouter);

// WRONG
app.use(requireAuth); // now applies to /health, /webhooks, everything
Enter fullscreen mode Exit fullscreen mode

Claude defaults to putting everything on app.use(). For APIs with mixed auth requirements (public + private routes, webhooks with their own auth), this creates security holes.


Rule 7: Never parse raw body and JSON body on the same route

## Body Parsing
Routes that need raw body (webhooks, Stripe, GitHub) MUST NOT have express.json()
applied to them. Use express.raw() on those specific routes only.

// CORRECT — raw body for webhook signature verification
app.use('/webhooks/stripe', express.raw({ type: 'application/json' }));
app.use(express.json()); // JSON for everything else

// WRONG — express.json() parses body before signature verification can run
app.use(express.json());
app.post('/webhooks/stripe', stripeHandler); // body already parsed, signature fails
Enter fullscreen mode Exit fullscreen mode

Claude generates webhook routes that fail Stripe/GitHub signature verification because the body gets parsed before the raw bytes are available. This rule prevents an hour of debugging.


Rule 8: Error responses use a consistent shape

## Error Response Shape
ALL error responses MUST use this exact shape:
{
  "error": {
    "message": "Human-readable description",
    "code": "MACHINE_READABLE_CODE",
    "status": 400
  }
}

Do NOT return { error: "string" }, { message: "string" }, or any other shape.
Validation errors return status 422, not 400.
Enter fullscreen mode Exit fullscreen mode

Without this rule, Claude invents a different error shape for every handler it writes. Your frontend ends up checking three different fields to find the error message.


Rule 9: Never expose stack traces in production

## Error Handler — Production Safety
The global error handler MUST check NODE_ENV before including stack traces.

app.use((err, req, res, next) => {
  const status = err.status || 500;
  const body = {
    error: {
      message: err.message || 'Internal Server Error',
      code: err.code || 'INTERNAL_ERROR',
      status,
    },
  };
  if (process.env.NODE_ENV !== 'production') {
    body.error.stack = err.stack;
  }
  res.status(status).json(body);
});
Enter fullscreen mode Exit fullscreen mode

Claude will include stack in error responses unless you specify otherwise. Stack traces in production responses are an information disclosure vulnerability.


Rule 10: Route files export routers, never mount themselves

## Module Pattern
Route files MUST export an express.Router() instance.
Route files must NOT call app.use() or import the app instance.

// routes/users.ts — CORRECT
const router = express.Router();
router.get('/', getUsers);
export default router;

// app.ts mounts it
app.use('/users', usersRouter);

// WRONG — circular deps, testing nightmare
import app from '../app';
app.use('/users', ...);
Enter fullscreen mode Exit fullscreen mode

Claude sometimes generates self-mounting route files, especially when working from existing app.ts files. This creates circular imports and makes unit testing routers impossible.


Rule 11: Use helmet() for all security headers

## Security Headers
ALL Express apps MUST use helmet() as the first middleware.
Do NOT configure individual security headers manually — use helmet's defaults.

import helmet from 'helmet';
app.use(helmet()); // first middleware, before body parsers

If a header needs customization, configure it through helmet's options,
not by calling res.setHeader() manually.
Enter fullscreen mode Exit fullscreen mode

Without this, Claude adds security headers ad-hoc and inconsistently. Helmet applies a well-tested set of headers in the correct order.


Rule 12: Test middleware in isolation

## Testing Middleware
Middleware functions MUST be unit-testable without starting an HTTP server.
Use node-mocks-http or manual mock req/res objects for middleware tests.
Integration tests (supertest) are for route testing, not middleware testing.

// middleware test — CORRECT
import { mockRequest, mockResponse } from 'node-mocks-http';
import { requireAuth } from './auth-middleware';

test('rejects unauthenticated request', () => {
  const req = mockRequest({ headers: {} });
  const res = mockResponse();
  const next = jest.fn();
  requireAuth(req, res, next);
  expect(res.statusCode).toBe(401);
  expect(next).not.toHaveBeenCalled();
});
Enter fullscreen mode Exit fullscreen mode

Claude writes integration tests for everything by default. Middleware tests through supertest are slow and test too much at once. Specify the testing pattern or you'll get a test suite that takes 30 seconds to run.


Rule 13: Environment configuration is always explicit and validated

## Configuration
App configuration MUST be loaded from environment variables and validated at startup.
Use a config module that throws on missing required variables — do NOT use
process.env.VARIABLE_NAME scattered throughout route handlers.

// config.ts
import { z } from 'zod';

const ConfigSchema = z.object({
  PORT: z.string().transform(Number),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  NODE_ENV: z.enum(['development', 'test', 'production']),
});

export const config = ConfigSchema.parse(process.env);
// Throws at startup if any required var is missing
Enter fullscreen mode Exit fullscreen mode

Claude will scatter process.env.DATABASE_URL throughout your handlers unless you establish a config module pattern. Missing environment variables then cause cryptic runtime errors instead of failing loudly at startup.


Putting it together

These 13 rules address the specific conventions that Express.js requires but that AI tools can't infer from your codebase alone. The async error handling (Rules 1–2), the four-argument error signature (Rule 3), the body parsing conflicts (Rule 7) — these are the bugs that show up in code review, not in the happy path.

A CLAUDE.md file that declares your stack versions, your async pattern, your error shape, and your module architecture means Claude generates code that fits your Express app instead of code that almost fits.

If you're using Claude Code or Cursor for an Express.js project, the full CLAUDE.md template — including rules for 23 other frameworks — is in the CLAUDE.md Rules Pack.

oliviacraftlat.gumroad.com/l/skdgt — $27, instant download

Top comments (0)