← All posts
·15 min read

Claude Code with Express.js: Middleware, Routing, and Security

Claude CodeExpressBackendWorkflow
Claude Code with Express.js: Middleware, Routing, and Security

Why Express needs explicit CLAUDE.md rules more than any other Node.js framework

Express is deliberately minimal. The framework ships with almost no opinions: no folder structure, no input validation, no security headers, no rate limiting, no structured logging, and no enforced middleware ordering. That design philosophy is what made Express the most-downloaded Node.js framework for a decade. It is also exactly what makes it dangerous when used with an AI code generator that has never seen your project conventions.

Claude knows Express well. It knows every Express version from 3.x through 5.x, every middleware package, every routing pattern, and every error-handling approach that has ever appeared in a tutorial. The problem is that it knows all of them simultaneously. Without project-specific rules, Claude will generate working code that mixes callback-style error handlers with async routes, omits helmet, configures CORS with origin: '*', puts database queries directly in route handlers, and uses Express 4.x sync middleware patterns in a codebase that has already upgraded to Express 5.x native promise support.

The failure mode is subtle. Claude-generated Express code compiles, runs, and appears correct in development. The problems surface in production: an uncaught async rejection crashes the process (Express 4.x), a missing await on a middleware chain produces a race condition, an unvalidated body field reaches the database layer.

The fix is a CLAUDE.md that answers the questions Express itself refuses to answer. This guide covers every rule you need, with a complete template, the five failure modes to eliminate, and code examples for the patterns that matter most. If you have not yet set up Claude Code in your project, the Claude Code setup guide covers installation and authentication. The CLAUDE.md explained reference explains how these rules are applied at session start.

The five failure modes Claude generates in Express projects

Before the CLAUDE.md template, it helps to name exactly what you are preventing. These are the five patterns Claude produces consistently without project rules, in order of how much damage they cause.

Sync error handlers in async routes. Express 4.x requires errors thrown in async route handlers to be explicitly passed to next(err). Claude will write async routes without try/catch and without calling next on rejection, causing unhandled promise rejections that crash the process in Node.js 15+. Express 5.x resolves this because it wraps route handlers in promise resolution automatically, but if your project is still on Express 4.x, you need the rule.

Missing or weak security middleware. Without instruction, Claude generates Express apps that omit helmet entirely or add it after body-parser, CORS configured with origin: '*' and no credentials restriction, and no rate limiting on authentication or mutation routes. Each of these is a standard security misconfiguration.

Middleware out of order. Express executes middleware in registration order, and that order matters. Claude will place body-parser before helmet, logging after route handlers, and error handlers anywhere but last. The correct order is: helmet, cors, rate-limit, request-id, logger, body-parser, routes, 404 handler, error handler. Claude gets this wrong in roughly half of generated app.ts files.

Business logic in route handlers. Claude puts database queries, third-party API calls, and complex transformations directly in route handler functions. This makes testing difficult, reuse impossible, and the controller hard to read. The router-controller-service separation fixes this, but Claude needs to be told that it exists and where each layer lives.

Mixing CommonJS and ESM in the same project. Claude will generate require() calls in files next to import statements, or produce import.meta.url patterns in a project configured for CommonJS. The CLAUDE.md needs to declare the module system once, clearly.

The Express CLAUDE.md template

This template is designed for Express 4.x or 5.x with TypeScript. The sections that differ between the two versions are called out inline.

# Express project rules

## Stack
- Node.js 20.x LTS
- Express 5.x (native async/await, no express-async-errors needed)
- TypeScript 5.x, strict mode, ESModules (import/export, not require)
- Zod 3.x for all request validation (body, params, query)
- Pino for structured JSON logging
- helmet, cors, express-rate-limit for security
- Vitest for unit tests, supertest for integration tests

## Project structure
- src/app.ts: Express app factory, middleware registration, router mounting
- src/server.ts: HTTP server creation, listen, graceful shutdown
- src/routes/: Express routers, one file per resource (users.router.ts)
- src/controllers/: Request handlers, call services, return responses
- src/services/: Business logic, no Express types (req/res) in scope
- src/middleware/: Custom middleware (auth, validate, logger, error-handler)
- src/schemas/: Zod schemas, imported by controllers and validators
- src/types/: Shared TypeScript types and Express augmentation (Request.user)

## Middleware order (MUST register in this exact sequence in app.ts)
1. helmet() -- security headers first
2. cors(corsOptions) -- before any route or body parsing
3. rateLimit(rateLimitOptions) -- global limit, tighten per-route if needed
4. requestId middleware -- adds X-Request-Id to each request
5. pino-http logger -- after request-id so it can log the id
6. express.json({ limit: '10kb' }) -- body parsing after logging
7. express.urlencoded({ extended: false, limit: '10kb' })
8. Routers (app.use('/api/v1/users', usersRouter))
9. 404 handler
10. Error handler (must be last, signature: (err, req, res, next))

## Async error handling (Express 5.x)
- Express 5.x natively catches errors thrown in async route handlers
- Do NOT use express-async-errors wrapper or manual try/catch in controllers
- Throw HttpError instances to trigger the error handler:
  throw new HttpError(404, 'User not found')
- The error handler in src/middleware/error-handler.ts reads err.status and err.message

## Async error handling (Express 4.x)
- Wrap every async route handler with asyncHandler from src/middleware/async-handler.ts
- OR use express-async-errors (import at the top of server.ts, before express import)
- Never use async route handlers without one of these two approaches

## Input validation
- Every route that reads req.body, req.params, or req.query MUST use Zod validation
- Validation runs via validateRequest middleware in src/middleware/validate.ts
- Schemas live in src/schemas/{resource}.schema.ts
- Validation errors return 422 with a structured error body (no raw Zod output)

## Security rules
- cors origin: read from CORS_ORIGIN env var (never hardcode, never '*' in production)
- helmet: use defaults (do not disable contentSecurityPolicy unless documented)
- Rate limiting: apply express-rate-limit to all /auth/* routes at max 10 req/15min
- Never log req.body (may contain credentials or PII)
- Never return stack traces in error responses in production (NODE_ENV check)

## Logging
- Use pino-http for request logging (structured JSON)
- Do not use console.log in any src/ file
- Log level from LOG_LEVEL env var, defaults to 'info'

## Environment
- All config from env vars via a typed config.ts (never process.env scattered inline)
- Required vars: PORT, NODE_ENV, DATABASE_URL, CORS_ORIGIN, JWT_SECRET
- config.ts validates all required vars at startup and throws if any are missing

## Hard rules
- No CommonJS (no require(), no module.exports)
- No any in TypeScript (use unknown and narrow)
- No raw SQL strings outside src/db/ (use parameterised queries or ORM methods)
- Error handler is always the last middleware registered
- No Express types (Request, Response) in service layer files

Middleware stack: the correct order in code

The order described in the CLAUDE.md template is easy to state and easy to get wrong when Claude generates app.ts without it. Here is the reference implementation Claude should match:

// src/app.ts
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { pinoHttp } from 'pino-http';
import { config } from './config.js';
import { usersRouter } from './routes/users.router.js';
import { notFoundHandler } from './middleware/not-found.js';
import { errorHandler } from './middleware/error-handler.js';
import { requestId } from './middleware/request-id.js';

export function createApp() {
  const app = express();

  // 1. Security headers
  app.use(helmet());

  // 2. CORS -- before body parsing and routes
  app.use(cors({
    origin: config.corsOrigin,
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
  }));

  // 3. Global rate limit
  app.use(rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 100,
    standardHeaders: true,
    legacyHeaders: false,
  }));

  // 4. Request ID
  app.use(requestId);

  // 5. Structured request logging
  app.use(pinoHttp({ logger: config.logger }));

  // 6. Body parsing -- after logging, with size limits
  app.use(express.json({ limit: '10kb' }));
  app.use(express.urlencoded({ extended: false, limit: '10kb' }));

  // 7. Routes
  app.use('/api/v1/users', usersRouter);
  app.use('/api/v1/health', (_req, res) => res.json({ status: 'ok' }));

  // 8. 404 -- after all routes
  app.use(notFoundHandler);

  // 9. Error handler -- always last
  app.use(errorHandler);

  return app;
}

Claude will generate this file with body-parser before helmet, or CORS after routes, without the template. With it, the generated order matches what is shown here.

Async error handling: Express 4.x versus 5.x

This is the single most important difference between Express versions, and Claude gets it wrong in both directions. On Express 4.x projects, Claude generates async handlers without error propagation. On Express 5.x projects, Claude adds unnecessary wrapper utilities that the framework no longer needs.

Express 5.x (native async support):

// src/routes/users.router.ts
import { Router } from 'express';
import { getUser, createUser } from '../controllers/users.controller.js';

export const usersRouter = Router();

// Express 5.x: async handlers are natively safe
// Thrown errors and rejected promises are caught and forwarded to error handler
usersRouter.get('/:id', getUser);
usersRouter.post('/', createUser);
// src/controllers/users.controller.ts
import { Request, Response } from 'express';
import { getUserById } from '../services/users.service.js';
import { HttpError } from '../middleware/http-error.js';
import { createUserSchema } from '../schemas/user.schema.js';

// Express 5.x: throw is safe, no try/catch needed at this layer
export async function getUser(req: Request, res: Response) {
  const user = await getUserById(req.params.id);
  if (!user) throw new HttpError(404, 'User not found');
  res.json(user);
}

export async function createUser(req: Request, res: Response) {
  const data = createUserSchema.parse(req.body); // Zod parse throws on invalid input
  const user = await createUserInDb(data);
  res.status(201).json(user);
}

Express 4.x (manual propagation required):

// src/middleware/async-handler.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';

type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;

export function asyncHandler(fn: AsyncHandler): RequestHandler {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}
// src/routes/users.router.ts (Express 4.x)
import { Router } from 'express';
import { asyncHandler } from '../middleware/async-handler.js';
import { getUser, createUser } from '../controllers/users.controller.js';

export const usersRouter = Router();

// Express 4.x: every async handler wrapped
usersRouter.get('/:id', asyncHandler(getUser));
usersRouter.post('/', asyncHandler(createUser));

Tell Claude which version you are on in the CLAUDE.md and it will use the correct pattern throughout. Without that rule, it defaults to whichever it has seen more recently in its training context, which is not reliable.

Zod request validation as middleware

Claude will generate Express routes with manual if (!req.body.email) checks or no validation at all. The correct pattern centralises validation in a reusable middleware using Zod schemas, returning structured 422 responses for invalid input.

// src/schemas/user.schema.ts
import { z } from 'zod';

export const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
  role: z.enum(['admin', 'member', 'viewer']).default('member'),
});

export const userParamsSchema = z.object({
  id: z.string().uuid(),
});

export type CreateUserInput = z.infer<typeof createUserSchema>;
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';

interface ValidateSchema {
  body?: AnyZodObject;
  params?: AnyZodObject;
  query?: AnyZodObject;
}

export function validateRequest(schema: ValidateSchema) {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      if (schema.body) req.body = await schema.body.parseAsync(req.body);
      if (schema.params) req.params = await schema.params.parseAsync(req.params);
      if (schema.query) req.query = await schema.query.parseAsync(req.query);
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        res.status(422).json({
          error: 'Validation failed',
          issues: err.issues.map(i => ({ path: i.path.join('.'), message: i.message })),
        });
        return;
      }
      next(err);
    }
  };
}
// Usage in router
usersRouter.post(
  '/',
  validateRequest({ body: createUserSchema }),
  createUser,
);

usersRouter.get(
  '/:id',
  validateRequest({ params: userParamsSchema }),
  getUser,
);

Adding this pattern to CLAUDE.md under the "Input validation" section means Claude will generate validateRequest({ body: schema }) middleware on every route that reads from the request, not inline checks.

The error handler and structured error responses

The error handler is always the last middleware registered in app.ts. Claude will sometimes generate it in the middle of the middleware stack, or generate a version that leaks stack traces in production, or fails to check err.status for HTTP errors versus generic 500s.

// src/middleware/http-error.ts
export class HttpError extends Error {
  constructor(
    public readonly status: number,
    message: string,
  ) {
    super(message);
    this.name = 'HttpError';
  }
}
// src/middleware/error-handler.ts
import { Request, Response, NextFunction } from 'express';
import { HttpError } from './http-error.js';
import { ZodError } from 'zod';
import { config } from '../config.js';

// Four-parameter signature is required -- Express identifies error handlers by arity
export function errorHandler(
  err: unknown,
  _req: Request,
  res: Response,
  _next: NextFunction,
) {
  if (err instanceof HttpError) {
    res.status(err.status).json({ error: err.message });
    return;
  }

  if (err instanceof ZodError) {
    res.status(422).json({
      error: 'Validation failed',
      issues: err.issues,
    });
    return;
  }

  // Unknown error: log it, return generic message
  config.logger.error({ err }, 'Unhandled error');
  res.status(500).json({
    error: config.nodeEnv === 'production' ? 'Internal server error' : String(err),
  });
}

The four-parameter signature (err, req, res, next) is not optional. Express identifies error-handling middleware by the presence of exactly four parameters. If Claude generates (err, req, res), the middleware is treated as a regular handler and errors bypass it. Add this as a hard rule in your CLAUDE.md.

Graceful shutdown and the server entry point

Separating the Express app factory from the server startup prevents a common problem: route tests import app.ts, which triggers a listen call, which leaves a dangling server handle that prevents the test runner from exiting cleanly.

// src/server.ts
import { createApp } from './app.js';
import { config } from './config.js';

const app = createApp();
const server = app.listen(config.port, () => {
  config.logger.info({ port: config.port }, 'Server started');
});

// Graceful shutdown
function shutdown(signal: string) {
  config.logger.info({ signal }, 'Shutdown signal received');
  server.close(() => {
    config.logger.info('HTTP server closed');
    process.exit(0);
  });

  // Force exit after 10 seconds if connections do not close
  setTimeout(() => {
    config.logger.error('Forced shutdown after timeout');
    process.exit(1);
  }, 10_000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

The unref() call on the forced-exit timeout prevents it from keeping the process alive on its own. Claude will generate shutdown handlers without unref(), which means in test environments the forced-exit timer holds the process open after the server closes. Add the separation of app.ts and server.ts to your CLAUDE.md project structure section.

What Claude gets right without instruction

Not everything needs a rule. Claude handles these Express patterns correctly without CLAUDE.md guidance, so you do not need to add rules for them:

  • Express Router() instantiation and basic method chaining
  • res.json(), res.status(), res.send(), and correct status code selection for CRUD operations
  • express.static() for serving static files
  • Route parameter syntax (/users/:id, /posts/:postId/comments/:commentId)
  • Basic app.set() configuration (trust proxy, case-sensitive routing)
  • Query string access via req.query

Focus CLAUDE.md rules on middleware ordering, async error propagation, validation, and security configuration. Rules about things Claude already does correctly add noise without value. The Claude Code best practices guide covers the general principle of minimal, high-signal CLAUDE.md rules across any framework.

Router-controller-service separation

This is the structural rule that has the most impact on long-term maintainability. Claude will put service logic in route handlers by default. Adding the three-layer separation to CLAUDE.md moves that logic to the right place.

The separation has one concrete boundary rule: Express types (Request, Response, NextFunction) never appear in service layer files. Services receive plain TypeScript values and return plain TypeScript values. This makes services testable without an HTTP context.

// src/services/users.service.ts
// No Express imports -- services are framework-agnostic
import { db } from '../db/client.js';
import type { CreateUserInput } from '../schemas/user.schema.js';

export async function getUserById(id: string) {
  return db.user.findUnique({ where: { id } });
}

export async function createUserInDb(data: CreateUserInput) {
  return db.user.create({ data });
}
// src/controllers/users.controller.ts
// Controllers know Express, call services, handle HTTP concerns
import { Request, Response } from 'express';
import { getUserById, createUserInDb } from '../services/users.service.js';
import { HttpError } from '../middleware/http-error.js';
import { createUserSchema } from '../schemas/user.schema.js';

export async function getUser(req: Request, res: Response) {
  const user = await getUserById(req.params.id);
  if (!user) throw new HttpError(404, 'User not found');
  res.json(user);
}

export async function createUser(req: Request, res: Response) {
  const data = createUserSchema.parse(req.body);
  const user = await createUserInDb(data);
  res.status(201).json(user);
}

When this boundary is declared in CLAUDE.md, Claude consistently places database and business logic in services, keeps controllers thin, and avoids the pattern of growing route handlers that become impossible to unit test. For database-specific patterns, the Claude Code with PostgreSQL and Claude Code with MongoDB guides cover the service layer in more depth, including transaction handling and connection pooling.

Typed configuration and environment validation

Claude generates process.env.PORT scattered across source files. Centralising configuration in a typed module that validates at startup catches missing environment variables before any request is served.

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

const envSchema = z.object({
  PORT: z.string().default('3000').transform(Number),
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  DATABASE_URL: z.string().url(),
  CORS_ORIGIN: z.string().url(),
  JWT_SECRET: z.string().min(32),
  LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'),
});

const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors);
  process.exit(1);
}

const env = parsed.data;

export const config = {
  port: env.PORT,
  nodeEnv: env.NODE_ENV,
  databaseUrl: env.DATABASE_URL,
  corsOrigin: env.CORS_ORIGIN,
  jwtSecret: env.JWT_SECRET,
  logger: pino({ level: env.LOG_LEVEL }),
} as const;

The z.string().min(32) on JWT_SECRET catches the development pattern of using a short placeholder secret that sometimes reaches staging. Add config.ts to the CLAUDE.md project structure description and Claude will import from it rather than reading process.env directly. For TypeScript-wide configuration patterns, the Claude Code with TypeScript guide covers the broader strict-mode setup this config file assumes.

Testing Express routes with supertest

Separating app.ts from server.ts pays off immediately in tests. The test file imports the app factory, not the running server.

// src/routes/users.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../app.js';
import type { Express } from 'express';

let app: Express;

beforeAll(() => {
  app = createApp();
});

describe('GET /api/v1/users/:id', () => {
  it('returns 422 for non-UUID id', async () => {
    const res = await request(app).get('/api/v1/users/not-a-uuid');
    expect(res.status).toBe(422);
    expect(res.body).toHaveProperty('issues');
  });

  it('returns 404 for unknown user', async () => {
    const res = await request(app).get('/api/v1/users/00000000-0000-0000-0000-000000000000');
    expect(res.status).toBe(404);
  });
});

describe('POST /api/v1/users', () => {
  it('returns 422 for missing email', async () => {
    const res = await request(app)
      .post('/api/v1/users')
      .send({ name: 'Test User' });
    expect(res.status).toBe(422);
  });
});

Add Vitest and supertest to the CLAUDE.md stack section. Claude will generate tests that use createApp() rather than importing from server.ts or, worse, starting an actual server on a real port. The Claude Code testing guide covers the broader test configuration for Node.js projects using Vitest.

Dockerising the Express service

A production Express deployment needs a multi-stage Docker build that separates the TypeScript compilation step from the runtime image. Claude will generate single-stage Dockerfiles that include devDependencies and TypeScript compiler in the final image without instruction.

The Claude Code with Docker guide covers the multi-stage pattern, non-root user, and health check configuration that apply directly to Express services. Add a reference to that guide in your CLAUDE.md if your Express project ships in containers, so Claude knows to follow the multi-stage pattern when it generates or updates the Dockerfile.

Self-review checklist before deploying Claude-generated Express code

These are the six checks to run on any Express code Claude generates, in order of how often the issue appears:

  1. Middleware registration order in app.ts matches the template sequence (helmet first, error handler last)
  2. Every async route handler in Express 4.x projects is wrapped with asyncHandler or express-async-errors is imported
  3. Every route reading req.body, req.params, or req.query has a validateRequest middleware call
  4. Error handler has exactly four parameters
  5. No process.env.FOO calls outside config.ts
  6. No Express types (Request, Response) in service files

Run these six checks and the code Claude generates for Express will be correct, secure, and maintainable. For the full production checklist including security headers and deployment, the Claude Code deployment guide covers the remaining layer.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir