Backend & APIs No. 082

Express on Node skill for Claude Code

Express 5 services that fail loudly and shut down cleanly. Claude structures Express apps around a testable app factory, orders middleware deliberately, validates bodies with Zod, and sends every failure through one problem+json error handler. It knows the Express 4 to 5 routing and async changes, tunes keep-alive against load balancer timeouts, and wires SIGTERM handling so deploys stop dropping in-flight requests.

  • nameexpress-node
  • size196 lines · ≈2,000 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds and fixes Express 5 services on Node.js 20+ - project layout, middleware order, async error handling, schema validation with Zod, centralized error responses, structured logging with pino, security middleware, timeouts, and graceful shutdown behind load balancers. Use when creating or reviewing an Express app, migrating Express 4 to 5, debugging hanging requests or unhandled rejections, or preparing a Node API for containers and production deploys.

Try asking

  • Set up a new Express 5 API with validation, logging and error handling
  • Migrate this Express 4 app to Express 5 and fix the broken routes
  • We get random 502s from the ALB on our Node service. Find the cause

Use it when

  • Creating or restructuring an Express application
  • Upgrading from Express 4 to Express 5
  • Requests hang, errors go unhandled, or deploys drop connections
  • Hardening a Node API before it goes behind a load balancer

Skip it when

  • You want decorators, DI and modules on Node — use nestjs-modules
  • The service runs on Workers, Bun or edge runtimes — use hono-edge
  • The problem is CPU or event-loop latency — use node-profiling

How to check the result

Route tests cover success, validation, auth and error paths, and sending SIGTERM during a slow request lets it finish before the process exits with code 0.

The common mistake it prevents

Leaving Node's keepAliveTimeout below the load balancer idle timeout, which produces intermittent 502s that never show up in application logs.

~/.claude/skills/express-node/SKILL.md

--- name: express-node description: Builds and fixes Express 5 services on Node.js 20+ - project layout, middleware order, async error handling, schema validation with Zod,… license: MIT ---

A production Express service validates every input at the edge, funnels every error through one handler that returns a consistent body, logs one structured line per request with a request ID, and shuts down without dropping in-flight requests.

Workflow

- [ ] 1. Check versions: node -v, express version, module system (ESM/CJS), TS or JS
- [ ] 2. Map the layout: app factory, routers, middleware order, error handler
- [ ] 3. Apply the target structure incrementally (do not rewrite working code wholesale)
- [ ] 4. Validate inputs, centralize errors, add request logging
- [ ] 5. Configure timeouts, proxy trust, security headers, body limits
- [ ] 6. Implement graceful shutdown; test it
- [ ] 7. Test routes with supertest against the app factory

1. Versions decide behavior

node -v && npm ls express && grep '"type"' package.json

Express 5 (default npm i express since 2025) differs from 4 in ways that cause bugs:

AreaExpress 4Express 5
Async handler throws/rejectsUnhandled rejection, request hangsForwarded to error middleware automatically
Wildcard routeapp.get('*')Named: app.get('/*splat'); root included via '/{*splat}'
Optional param/:id?{/:id}
Regex chars in pathsAllowedNot supported in strings; use arrays or explicit routes
req.body without parser{}undefined
express.urlencodedextended: true defaultextended: false default
Removed-app.del, req.param(), res.sendfile, res.send(status) signature

On Express 4, wrap async handlers ((fn) => (req, res, next) => fn(req, res, next).catch(next)) or upgrade. Migration codemods: npx @expressjs/codemod upgrade.

2. Target layout

src/
  app.ts            # createApp(deps): builds and returns the express app (no listen)
  server.ts         # reads config, calls createApp, listen, shutdown hooks
  config.ts         # parse and validate env once (zod), export typed config
  routes/orders.ts  # Router per resource; thin handlers
  services/         # business logic, no req/res
  middleware/       # auth, validate, requestId, errorHandler
  errors.ts         # AppError hierarchy

Separating createApp from listen lets tests import the app without opening a port.

3. Middleware order

import express from "express";
import helmet from "helmet";
import pinoHttp from "pino-http";
import { randomUUID } from "node:crypto";

export function createApp(deps: Deps) {
  const app = express();
  app.set("trust proxy", 1);                 // exactly the number of proxies in front
  app.disable("x-powered-by");
  app.use(pinoHttp({
    genReqId: (req, res) => {
      const id = (req.headers["x-request-id"] as string) ?? randomUUID();
      res.setHeader("x-request-id", id);
      return id;
    },
    redact: ["req.headers.authorization", "req.headers.cookie"],
  }));
  app.use(helmet());
  app.use(express.json({ limit: "100kb" }));
  app.get("/healthz", (_req, res) => res.send("ok"));       // before auth
  app.use("/v1/orders", authenticate(deps), ordersRouter(deps));
  app.use((_req, res) => res.status(404).json({ type: "about:blank", title: "Not Found", status: 404 }));
  app.use(errorHandler);                      // last, four arguments
  return app;
}

4. Validation and errors

import { z } from "zod";

export const validate = <T extends z.ZodTypeAny>(schema: T) =>
  (req: Request, _res: Response, next: NextFunction) => {
    const parsed = schema.safeParse(req.body);
    if (!parsed.success) return next(new ValidationError(parsed.error.issues));
    req.body = parsed.data;                   // stripped of unknown keys
    next();
  };

export class AppError extends Error {
  constructor(public status: number, public type: string, message: string, public extra?: object) {
    super(message);
  }
}

export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) {
  if (res.headersSent) return _next(err);
  if (err instanceof AppError) {
    return res.status(err.status).type("application/problem+json")
      .json({ type: err.type, title: err.message, status: err.status, ...err.extra });
  }
  req.log.error({ err }, "unhandled error");
  res.status(500).type("application/problem+json")
    .json({ type: "about:blank", title: "Internal Server Error", status: 500, traceId: req.id });
}

Validate req.params and req.query too; in Express 5 req.query is a getter, so store parsed values on res.locals instead of reassigning it.

5. Production settings

  • trust proxy: set to the hop count (or subnet) of your load balancers. true lets any client spoof X-Forwarded-For, breaking IP rate limits.
  • Keep-alive: Node’s server.keepAliveTimeout must exceed the load balancer idle timeout or the LB reuses a socket Node just closed and returns sporadic 502s. For AWS ALB (60 s default): server.keepAliveTimeout = 65_000; server.headersTimeout = 66_000;
  • server.requestTimeout (default 300 s in Node 18+) bounds slow uploads; lower it for APIs.
  • Body limits on every parser. Never express.json() globally on routes that need raw bodies (webhooks); mount express.raw({ type: "application/json" }) on those first.
  • Use node --env-file=.env (Node 20.6+) in dev instead of dotenv; validate config at boot and exit non-zero on missing vars.
  • Never block the event loop: no sync crypto (pbkdf2Sync), fs.*Sync, or huge JSON.parse in handlers.

6. Graceful shutdown

const server = app.listen(config.port, () => logger.info({ port: config.port }, "listening"));
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;

let shuttingDown = false;
async function shutdown(signal: string) {
  if (shuttingDown) return;
  shuttingDown = true;
  logger.info({ signal }, "shutting down");
  server.close(async () => {              // stops accepting; waits for in-flight
    await db.end();                       // then release pools, flush queues
    process.exit(0);
  });
  server.closeIdleConnections();          // drop idle keep-alive sockets now
  setTimeout(() => process.exit(1), 25_000).unref();   // below orchestrator grace period
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("unhandledRejection", (err) => { logger.fatal({ err }); process.exit(1); });

During shutdown, make /healthz (readiness) return 503 so the LB stops routing before connections close. In Docker, run node as PID 1 directly (CMD ["node", "dist/server.js"]) or use --init; npm start swallows SIGTERM.

7. Tests

import request from "supertest";
const app = createApp(fakeDeps());
test("rejects invalid order", async () => {
  const res = await request(app).post("/v1/orders").send({ items: [] }).set("authorization", "Bearer t");
  expect(res.status).toBe(422);
  expect(res.headers["content-type"]).toMatch(/problem\+json/);
});

Gotchas

  • Error middleware must have exactly four parameters or Express treats it as normal middleware.
  • Calling next() after sending a response causes ERR_HTTP_HEADERS_SENT; return after res.*.
  • res.json on a BigInt throws; serialize explicitly.
  • cors() with origin: true and credentials: true reflects any origin with cookies; whitelist origins.
  • Route order matters: /orders/:id declared before /orders/export captures “export”.
  • Do not create DB clients per request; create once at boot, inject via createApp(deps).

Verification

  1. npm test passes with route tests for success, validation failure, auth failure, 404, and 500 paths.
  2. Send SIGTERM during a slow request (curl a route with an artificial delay, then kill -TERM <pid>): the request completes and the process exits 0.
  3. curl -i shows x-request-id, security headers, and problem+json on errors.
  4. Logs are one JSON line per request with method, URL, status, duration, and request ID.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc