Claude Code Express.js Node.js CLAUDE.md AI Coding 2026

Claude Code for Express.js: CLAUDE.md Rules for Middleware, Error Handling, and Async Routes (2026)

The Prompt Shelf ·

npm install express has installed Express 5 by default since it became the Express Technical Committee’s production-recommended release, and Express 4 now has a published target end-of-life of no sooner than October 1, 2026. That matters more than a routine version bump: Express 5 catches rejected promises inside route handlers automatically, while Express 4 silently swallows them unless you wrap every async handler yourself. Claude Code’s training data spans years of Express 4 code, so left without a CLAUDE.md, it defaults to patterns that were correct for years and are now a liability on a fresh Express 5 install — an unhandled rejection that hangs a request instead of returning an error.

Express also isn’t NestJS. NestJS bakes structure in with decorators, modules, and dependency injection — Claude Code has strong scaffolding to lean on even without project-specific rules (our gallery’s NestJS best practices ruleset is a good example of how much a framework can pre-decide). Plain Express decides almost nothing. Routing, error handling, validation, and project layout are all just conventions your team picked, and nothing in the framework enforces them. That’s exactly the gap a CLAUDE.md needs to fill, and it’s a gap our gallery doesn’t currently have a dedicated rule set for — everything Express-related in it shows up as a dependency inside a more opinionated framework, not as its own reference.

Why Plain Express Needs More Rules Than a Structured Framework

Nothing enforces async error handling. A route handler that throws inside an async function used to fail silently in Express 4 — the promise rejected, nothing caught it, and the request just hung until a timeout. Express 5 fixed this at the framework level, but plenty of production code still runs on Express 4, and Claude Code has no way to know which one your project is on unless CLAUDE.md says so explicitly.

Error-handling middleware is identified by argument count, not a keyword. Express decides whether a middleware function is an error handler by checking if it declares exactly four parameters — (err, req, res, next). Drop next because a linter flags it as unused, and Express silently treats the function as regular middleware instead of an error handler. It still compiles. It just never runs.

There’s no built-in validation layer. Django has forms and serializers, Rails has strong params, NestJS has class-validator with decorators. Express has whatever your team chose — Zod, Joi, express-validator, or nothing — and an agent without a documented answer will reach for whatever pattern shows up most in its training data, which is rarely the one your project already uses.

Project structure is a convention, not a contract. routes/, controllers/, services/, middleware/ is a common shape, but Express doesn’t require it and doesn’t generate it. Every layer boundary — “controllers never touch the database directly,” “services are framework-agnostic” — exists only because someone wrote it down, and Claude Code can’t infer a boundary that isn’t documented anywhere.

Complete CLAUDE.md Template for Express.js Projects

This targets Express 5.x on Node.js 22 LTS. If your project is still on Express 4, keep the version line and swap the async error handling section for the Express 4 pattern shown in the next section.

# Express.js API: [ProjectName]

## Build & Run
- Dev server (watch mode): `npm run dev`
- Production start: `npm start`
- Run all tests: `npm test`
- Run a single test file: `npm test -- path/to/file.test.ts`
- Lint: `npm run lint`
- Type check (if TypeScript): `npm run typecheck`

## Runtime
- Node.js 22 LTS, Express 5.x — do not suggest Express 4 patterns (manual asyncHandler wrappers, `app.del()`) unless this project is explicitly pinned to Express 4
- TypeScript, strict mode — no `any` in route handlers or middleware signatures

## Project Structure (do not flatten this)
- `src/routes/` — path declarations and middleware wiring only, no business logic
- `src/controllers/` — reads `req`, calls a service, writes `res` — no direct database or ORM calls
- `src/services/` — business logic, framework-agnostic (no `req`/`res` imported here)
- `src/middleware/` — auth, validation, rate limiting, error handling
- `src/lib/` — shared utilities (db client, logger, config)
- `src/types/` — shared TypeScript types and Zod schemas

## Async Route Handlers
- Express 5 catches rejected promises in `async` route handlers automatically — do not wrap handlers in `asyncHandler()` or a try/catch that only re-throws
- Controllers throw `HttpError` subclasses; they never call `res.status(500).json(...)` directly
- Never mix `.then()/.catch()` chains with `async/await` in the same function

## Error Handling
- One `HttpError` class: `status`, `message`, optional `code`/`details` — no ad hoc error shapes
- Exactly one error-handling middleware, mounted last, after all routes
- Error-handling middleware MUST declare all four parameters `(err, req, res, next)` — Express detects error handlers by arity; a 3-parameter signature is silently treated as normal middleware
- Stack traces only appear in responses when `NODE_ENV !== 'production'`

## Validation
- Zod schemas in `src/types/`, applied via a `validate(schema)` middleware at the route boundary
- Controllers read from the parsed, typed request object — never read raw `req.body.x` directly in a controller
- Validation failures return 400 with field-level error details, formatted by the same error middleware as everything else

## Middleware Order (do not reorder without asking)
1. `helmet()`, CORS
2. Request logging
3. Body parsing
4. Rate limiting (auth routes especially)
5. Route-specific middleware (auth guard, validation)
6. Route handlers
7. 404 handler
8. Error-handling middleware (last, always)

## Testing
- Supertest against the Express app instance directly — never start a real server/port in tests
- One test file per route module, mirroring `src/routes/` structure
- Mock the service layer in controller tests; use a test database (not mocks) in service-layer tests

The Express 4-to-5 Async Error Trap

This is the single most consequential difference for a CLAUDE.md, because both versions compile and both look correct in a code review — they just fail differently.

// Express 4 — a thrown error inside an async handler is NOT caught automatically
app.get('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id); // rejects → unhandled rejection, request hangs
  res.json(user);
});

// Express 4 fix — wrap every async handler explicitly
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json(user);
}));
// Express 5 — rejected promises in async handlers are forwarded to error middleware automatically
app.get('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id); // rejects → automatically caught, passed to error handler
  res.json(user);
});

The failure mode without a rule: Claude Code writes the Express 4 asyncHandler() pattern on an Express 5 project because that pattern dominates its training data, and the wrapper is harmless-but-noisy. The more damaging version runs the other way — Claude Code trusts Express 5’s automatic catching on a project that’s still pinned to Express 4, and a thrown error inside a route handler goes uncaught, hanging the request until a client-side timeout with no server-side error log at all. State your Express major version explicitly at the top of CLAUDE.md, next to the Node version, so there’s no ambiguity about which pattern applies.

The Four-Parameter Error Middleware Trap

Express doesn’t look for a keyword or a special export to identify error-handling middleware — it counts function parameters. Exactly four means “error handler.” Anything else means “regular middleware,” and Express will never call it when an error occurs.

// Bad — a linter or an "unused variable" pass drops `next`, and this silently stops being an error handler
app.use((err, req, res) => {
  res.status(err.status || 500).json({ message: err.message });
});

// Good — all four parameters present, even though `next` is unused inside the body
app.use((err, req, res, next) => {
  const status = err.status || 500;
  const body = { message: err.message };
  if (process.env.NODE_ENV !== 'production') body.stack = err.stack;
  res.status(status).json(body);
});

There’s no error at startup and no error at request time that points back to “wrong arity.” The route that triggers the error just falls through to Express’s default HTML error page instead of your JSON error format, and it’s easy to miss in testing if your test suite doesn’t specifically assert on error-response shape. This is exactly the kind of rule that costs nothing to write down and is expensive to debug without one — a single line in CLAUDE.md stating the four-parameter requirement removes the ambiguity entirely.

Validation at the Boundary, Not in the Controller

Without a documented validation layer, an agent will scatter if (!req.body.email) return res.status(400)... checks across controllers, inconsistently, because there’s no framework convention forcing otherwise.

// middleware/validate.ts
import { ZodSchema } from 'zod';
import { Request, Response, NextFunction } from 'express';

export const validate = (schema: ZodSchema) =>
  (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return next(new HttpError(400, 'Validation failed', { issues: result.error.issues }));
    }
    req.body = result.data; // now typed and parsed
    next();
  };
// routes/users.ts
router.post('/users', validate(createUserSchema), usersController.create);
// controllers/users.ts — reads the already-validated, typed body — no raw req.body access
export const create = async (req: Request, res: Response) => {
  const user = await usersService.createUser(req.body);
  res.status(201).json(user);
};

The controller never touches req.body directly and never repeats validation logic that already ran in middleware. This is a pattern Django, Rails, and NestJS give you by default; Express gives you nothing, so it has to be a rule, not an assumption.

Hook-Driven Verification for Express Projects

Full integration test suites against a real database are too slow to run after every edit, but a fast route-level check catches most agent-introduced regressions immediately.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "npx tsc --noEmit"
          }
        ]
      }
    ]
  }
}
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "npm test -- --silent"
          }
        ]
      }
    ]
  }
}

Keep any test that hits a live external service (a real payment gateway, a third-party API) out of both hooks — gate those behind CI with recorded fixtures or a sandbox credential instead, the same way instrumented device tests get excluded from mobile CLAUDE.md hook configs.

AGENTS.md Compatible Version

# AGENTS.md — Express.js API

## Commands
- dev: `npm run dev`
- test: `npm test`
- lint: `npm run lint`
- typecheck: `npx tsc --noEmit`

## Critical Rules
1. Express 5.x — async route handlers catch rejected promises automatically, no asyncHandler() wrapper needed
2. Error-handling middleware MUST have exactly 4 parameters: (err, req, res, next)
3. One error-handling middleware, mounted last, after all routes
4. Controllers throw HttpError subclasses — never call res.status(500) directly
5. Zod validation via middleware at the route boundary — controllers never read raw req.body
6. Layer boundaries: routes → controllers → services → data layer, no skipping layers
7. Services are framework-agnostic — no req/res imported in src/services/

## Testing
- Supertest against the app instance directly, never a real listening port
- Mock the service layer in controller tests

Common AI + Express Mistakes to Watch For

Wrapping Express 5 handlers in an Express 4 asyncHandler(). Harmless but redundant — a sign Claude Code hasn’t been told which major version the project is on, and worth fixing before it spreads across every new route.

A three-parameter function passed to app.use() where an error handler was intended. Silent failure, not a crash — the route falls through to Express’s default error page instead of your JSON format. Worth a specific test asserting on error-response shape, not just a manual check.

Validation logic duplicated inside a controller that already has a validate(schema) middleware available. Usually happens when Claude Code adds a new route without noticing the existing middleware pattern — point it at an existing validated route as a reference when adding a new one.

A service function importing req or res directly instead of receiving plain arguments. Breaks the framework-agnostic boundary that makes services testable without spinning up Express at all, and it’s an easy mistake because Express makes req/res available anywhere in the call chain if you let it.

Rate limiting applied globally instead of per-route, especially on auth endpoints. A single global rate limiter set high enough not to block normal traffic on read endpoints is usually far too permissive for a login or password-reset route, which needs its own tighter limit.


The pattern across all of these is the same one that shows up in every unopinionated framework: nothing fails loudly, so an agent working without a CLAUDE.md produces code that runs, passes a quick manual check, and only breaks the specific way your team already learned to avoid. NestJS’s decorators catch a chunk of these by construction — plain Express catches none of them, which is exactly why the rules above earn their place in the file even on a small project.

Browse the NestJS ruleset in our gallery for a comparison point on how much structure a framework can pre-decide versus what Express leaves for CLAUDE.md to cover, or browse all backend rule sets for more language- and framework-specific examples.


FAQ

Do I need different CLAUDE.md rules for Express 4 vs. Express 5? Only the async error handling section changes. Express 5 catches rejected promises in async route handlers automatically; Express 4 does not, and needs an explicit asyncHandler() wrapper (or an equivalent library) documented as a requirement. Everything else in this template — error middleware arity, validation-at-the-boundary, project structure — applies to both versions unchanged.

Should this replace a NestJS CLAUDE.md if my project is on NestJS instead of plain Express? No — NestJS’s decorators, modules, and built-in validation pipe already enforce most of what this template writes down manually for plain Express. A NestJS project needs a different, shorter CLAUDE.md focused on module boundaries and decorator conventions; our gallery’s NestJS ruleset is a better starting point for that stack.

Why call out the four-parameter error middleware rule specifically — isn’t that documented in Express’s own docs? It’s documented, but it’s exactly the kind of framework trivia that’s easy to lose to a linter’s “unused variable” warning on next, and the failure is silent — no error at startup, no error at request time, just an error handler that never runs. A one-line rule in CLAUDE.md costs nothing and removes an entire class of debugging session.

Does Zod validation belong in CLAUDE.md, or should I just pick any validation library and let Claude Code follow whatever’s already in the codebase? If a validation library is already in the codebase, name it explicitly in CLAUDE.md rather than relying on Claude Code to infer it from existing imports — mixed validation approaches (some routes using Zod, others using manual if checks) are one of the more common inconsistencies we see in Express projects that don’t document a single standard.

Related Articles

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules