Express 5 API skill for Codex

Express services with one error path and typed input. Codex structures Express 5 apps with an importable app factory, feature routers and services free of req and res, and a single error handler that catches rejected promises. Input is validated with Zod before business logic runs, proxies and keep-alive timeouts are set deliberately, and Supertest covers each route's failure modes. A migration reference walks Express 4 apps through path-to-regexp v8 and removed APIs.

  • Backend & APIs
  • $express-api
  • 233 lines
  • 3 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- express-api

Installs to ~/.agents/skills/express-api/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $express-api, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds and maintains Express 5 APIs in TypeScript or JavaScript - app/server split, router-per-feature layout, middleware order, async error propagation, a single error handler, Zod request validation, security middleware, structured logging, graceful shutdown and Supertest tests. Includes the Express 4 to 5 migration traps (path-to-regexp v8 wildcards, read-only req.query, removed methods). Use when creating an Express service, adding routes or middleware, fixing unhandled promise rejections or hanging requests, or upgrading from Express 4.

Use it when

  • Creating a new Express service or adding routes and middleware to one
  • Upgrading from Express 4 to Express 5
  • Errors leak stack traces, crash the process, or requests hang without responding
  • An Express app needs graceful shutdown behind a load balancer

Use something else when

  • You want schema-first performance and plugin encapsulation: use fastify-api
  • The API must run on Cloudflare Workers or other edge runtimes: use hono-edge-api
  • You are designing resource URLs and status codes rather than wiring Express: use rest-api-design

How to check the result

Type-check and run the test suite, then curl a malformed JSON body, an unknown path and a throwing route: each returns the project's JSON error shape with the right status, never an HTML page.

The mistake it prevents

Keeping Express 4 wildcard routes like app.get('*') after upgrading, which path-to-regexp v8 rejects at startup or matches differently.

~/.agents/skills/express-api/SKILL.md

--- name: express-api description: Builds and maintains Express 5 APIs in TypeScript or JavaScript - app/server split, router-per-feature layout, middleware order, async error… license: MIT ---

Done well means: every route validates its input before touching business logic, every error (sync, async, thrown or rejected) reaches one error handler that returns a consistent JSON body, the app object is importable without opening a port so tests run in-process, and the server drains connections on SIGTERM.

Inspect before changing anything

  • Read AGENTS.md and package.json: Express major version, module type ("type": "module" or CommonJS), TypeScript setup, test runner, Node version (Express 5 needs Node 18+).
  • npm ls express (or pnpm/yarn equivalent) to confirm the installed version; v4 and v5 differ in routing syntax and error handling.
  • Find the middleware chain: rg -n "app\.use\(|router\.use\(" src.
  • Find the existing error handler: rg -n "\(err, req, res, next\)|ErrorRequestHandler" src.
  • If the project is on Express 4 and the task is an upgrade, read references/express-5-migration.md before editing.

Layout

src/
  app.ts              # builds and exports the app, no listen()
  server.ts           # listen(), shutdown signals
  config.ts           # env parsing (validated once at boot)
  middleware/         # auth, requestId, validate, errorHandler
  modules/
    orders/
      orders.router.ts
      orders.schemas.ts
      orders.service.ts   # no req/res here: plain inputs, plain outputs
      orders.test.ts

Keep req/res out of services so they can be called from jobs, scripts and tests.

Middleware order

// app.ts
import express from "express";
import helmet from "helmet";
import cors from "cors";
import pinoHttp from "pino-http";

export function createApp(deps: Deps) {
  const app = express();
  app.disable("x-powered-by");
  app.set("trust proxy", 1);                 // exactly the number of proxies in front
  app.use(pinoHttp({ genReqId: (req) => req.headers["x-request-id"] ?? crypto.randomUUID() }));
  app.use(helmet());
  app.use(cors({ origin: deps.config.corsOrigins, credentials: true }));
  app.use(express.json({ limit: "100kb" }));

  app.get("/healthz", (_req, res) => { res.json({ ok: true }); });
  app.use("/v1/orders", ordersRouter(deps));

  app.use(notFound);                          // after all routes
  app.use(errorHandler);                      // last, 4 arguments
  return app;
}
ConcernPlacement rule
Request id / loggingFirst, so every later log line carries the id
Security headers, CORSBefore routes; CORS before auth so preflights succeed
Body parsersGlobal with a size limit, or per-route for webhooks needing the raw body
Rate limitingBefore expensive work; keyed per user or API key, not only per IP
AuthPer router, so health checks and public routes stay open
404 + error handlerLast

trust proxy set to true lets any client spoof X-Forwarded-For, which breaks IP-based rate limits. Use the hop count or the proxy’s subnet.

Async errors

Express 5 forwards rejected promises from handlers and middleware to the error handler, so express-async-errors and manual try/catch + next(err) wrappers are unnecessary. Remove them during upgrades. Errors thrown inside callbacks that Express does not await (timers, event emitters, un-awaited promises) still escape: await everything.

// errors.ts
export class AppError extends Error {
  constructor(public status: number, public code: string, message: string, public details?: unknown) {
    super(message);
  }
}

// middleware/errorHandler.ts
import type { ErrorRequestHandler } from "express";
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
  if (res.headersSent) return next(err);       // let Express close the connection
  if (err instanceof AppError) {
    return res.status(err.status).json({ error: { code: err.code, message: err.message, details: err.details } });
  }
  if (err?.type === "entity.parse.failed") {
    return res.status(400).json({ error: { code: "invalid_json", message: "Malformed JSON body" } });
  }
  if (err?.type === "entity.too.large") {
    return res.status(413).json({ error: { code: "payload_too_large", message: "Body too large" } });
  }
  req.log.error({ err }, "unhandled error");
  res.status(500).json({ error: { code: "internal", message: "Internal server error" } });
};

Never send err.message or stack traces for unknown errors to clients. If the project standardises on RFC 9457 problem details, follow $problem-details-errors for the shape.

Validation

Validate params, query and body at the edge with Zod (or the project’s existing library). In Express 5, req.query is a getter and cannot be reassigned, so put parsed values somewhere else:

import type { RequestHandler } from "express";
import { z, type ZodType } from "zod";

export const validate = <B, Q, P>(s: { body?: ZodType<B>; query?: ZodType<Q>; params?: ZodType<P> }): RequestHandler =>
  (req, res, next) => {
    const out: Record<string, unknown> = {};
    for (const key of ["body", "query", "params"] as const) {
      const schema = s[key];
      if (!schema) continue;
      const r = schema.safeParse(req[key]);
      if (!r.success) throw new AppError(422, "validation_failed", `Invalid ${key}`, r.error.issues);
      out[key] = r.data;
    }
    res.locals.input = out;
    next();
  };

router.post("/", validate({ body: CreateOrder }), async (req, res) => {
  const order = await deps.orders.create(req.user.id, res.locals.input.body);
  res.status(201).location(`/v1/orders/${order.id}`).json(order);
});

express.json() leaves req.body as undefined (not {}) when no body was parsed in Express 5; schemas must handle that.

Routing rules (Express 5 / path-to-regexp v8)

Express 4Express 5
app.get("*", ...)app.get("/*splat", ...) (wildcards must be named)
"/files/*""/files/*path"; req.params.path is an array of segments
"/:file.:ext?""/:file{.:ext}" (braces mark optional parts)
Regex characters in string paths ("/user/:id(\\d+)")Not supported: validate id in the handler or schema

Mount routers with app.use(prefix, router) and use express.Router({ mergeParams: true }) when a child router needs parent params.

Graceful shutdown

// server.ts
const server = app.listen(config.port, () => logger.info({ port: config.port }, "listening"));
server.keepAliveTimeout = 65_000;   // above the load balancer idle timeout (ALB default 60 s)
server.headersTimeout = 66_000;     // must exceed keepAliveTimeout

for (const sig of ["SIGTERM", "SIGINT"] as const) {
  process.once(sig, () => {
    logger.info({ sig }, "draining");
    server.close(() => { void deps.db.end().then(() => process.exit(0)); });
    server.closeIdleConnections();
    setTimeout(() => process.exit(1), 10_000).unref();
  });
}

Testing

import request from "supertest";
const app = createApp(testDeps());

it("rejects an invalid order with 422", async () => {
  const res = await request(app).post("/v1/orders").set("Authorization", `Bearer ${token}`).send({ qty: -1 });
  expect(res.status).toBe(422);
  expect(res.body.error.code).toBe("validation_failed");
});

Cover per route: happy path, validation failure, unauthenticated, forbidden (other user’s resource), not found, and one case where the service throws (500 body hides internals).

Gotchas

  • Middleware that neither responds nor calls next() hangs the request until the client times out. Every branch must do one or the other.
  • res.json() after a response was sent throws ERR_HTTP_HEADERS_SENT; return after responding.
  • A raw-body webhook route must be registered before the global express.json(), or use express.raw({ type: "application/json" }) on that route only.
  • app.listen() inside app.ts makes tests open real ports and leak handles.
  • res.status() in Express 5 throws on invalid codes (non-integers, out of 100-999); do not pass through upstream status values unchecked.
  • cors({ origin: "*", credentials: true }) is rejected by browsers; list origins.

Verification

npx tsc --noEmit            # if TypeScript
npm test                    # or the command from AGENTS.md

Then start the server and check one route of each kind with curl, including a malformed JSON body (expect 400 JSON, not an HTML stack page) and an unknown path (expect the JSON 404). If anything returns HTML, the error handler is missing or registered too early.

Output template

## Express change: <summary>
- Routes: <method path -> status codes>
- Validation: <schemas added>
- Middleware order changes: <if any>
- Error codes introduced: <code: status>
- Tests: <file: cases>
- Follow-ups: <e.g. rate limit store, OpenAPI update>

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Express 5 API"
  short_description: "Express services with one error path and typed input"
  default_prompt: "$express-api Add a CRUD orders API to our Express app with validation and tests"
references/express-5-migration.md84 lines, 4.3 KB
# Express 4 to 5 migration checklist

Read this only when upgrading an existing Express 4 app. Work on a branch, upgrade in one
commit, and keep behaviour changes (new validation, new error shapes) out of that commit.

## Order of work

```markdown
- [ ] Confirm Node >= 18 in engines, CI image and Dockerfile
- [ ] Run the official codemods (needs network; ask before running npx in the sandbox)
- [ ] npm install express@5 (and @types/express@5 for TypeScript)
- [ ] Fix route strings (path-to-regexp v8)
- [ ] Fix removed or renamed APIs
- [ ] Remove async error wrappers
- [ ] Check req.query, req.body and query-parser behaviour
- [ ] Upgrade middleware with Express 5 support (check each package's changelog)
- [ ] Full test suite + manual smoke test of every router prefix
```

The official migration guide at expressjs.com lists the current codemod command (at the
time of writing, `npx codemod@latest @expressjs/v5-migration-recipe`). Codemods handle
renamed methods, not route syntax or behaviour changes, so still walk the list below.

## Route syntax (path-to-regexp v8)

Find candidates: `rg -n "['\"\`][^'\"\`]*[*?()][^'\"\`]*['\"\`]" src --glob '*router*' --glob '*routes*'`

| Old | New | Note |
|---|---|---|
| `"*"` | `"/*splat"` | Does not match `/` itself; use `"/{*splat}"` to include the root |
| `"/assets/*"` | `"/assets/*path"` | `req.params.path` is an array; join with `/` |
| `"/:id?"` | `"/{:id}"` | Optional segments use braces |
| `"/:file.:ext?"` | `"/:file{.:ext}"` | |
| `"/user/:id(\\d+)"` | `"/user/:id"` + validate in schema | Inline regex removed |
| `"/a+"`, `"/a?b"` | Explicit paths or a RegExp object | `?`, `+`, `()` are reserved characters |

Regex literals (`app.get(/^\/v\d+\//, ...)`) still work.

## Removed or changed APIs

| Express 4 | Express 5 |
|---|---|
| `app.del()` | `app.delete()` |
| `req.param(name)` | `req.params`, `req.body` or `req.query` explicitly |
| `res.json(obj, status)` / `res.json(status, obj)` | `res.status(status).json(obj)` |
| `res.send(status)` with a number | `res.sendStatus(status)` |
| `res.sendfile()` | `res.sendFile()` |
| `res.redirect("back")` / `res.location("back")` | `res.redirect(req.get("Referrer") ?? "/")` |
| `res.redirect(url, status)` | `res.redirect(status, url)` |
| `req.acceptsCharset` / `acceptsEncoding` / `acceptsLanguage` | Pluralised: `acceptsCharsets`, `acceptsEncodings`, `acceptsLanguages` |
| `express.urlencoded()` default `extended: true` | Default `extended: false`; pass `extended: true` if nested form fields are needed |
| `app.set("query parser", "extended")` default | Default is `"simple"`: `?a[b]=1` is no longer parsed into objects |

## Behaviour changes that break silently

- **Async errors**: rejected promises go to the error handler automatically. Delete
  `express-async-errors`, `express-async-handler` and hand-written `wrap()` helpers, or
  they may double-handle errors.
- **`req.query` is a getter.** Code that assigns `req.query = parsed` or mutates it in
  middleware stops working. Store parsed values on `res.locals` or a typed request
  property.
- **`req.body` is `undefined`** when no parser ran, instead of `{}`. Guard destructuring
  like `const { a } = req.body`.
- **`res.status()`** throws for non-integer or out-of-range codes.
- **`express.static` `dotfiles`** now defaults to `"ignore"`; serving `/.well-known/`
  needs an explicit static mount for that directory.
- **`app.listen` errors** (for example `EADDRINUSE`) are now passed to the callback;
  check the argument instead of assuming success.
- **`req.host`** keeps the port number.
- **`res.clearCookie`** ignores `maxAge` and `expires` options.

## Middleware compatibility

Check each of these against its changelog before upgrading: router-level auth libraries
(Passport works), `express-rate-limit`, `multer`, `http-proxy-middleware`, `express-session`,
`helmet`, `cors`, OpenAPI validators (`express-openapi-validator` route parsing), and
any package that rewrites `req.query`. TypeScript projects need `@types/express@5`; its
`RequestHandler` return type allows promises.

## Done when

- `rg -n "app\.del\(|req\.param\(|sendfile\(|res\.json\([0-9]" src` returns nothing.
- Every router prefix has at least one passing request in tests or a curl smoke test.
- An intentionally failing async handler returns the JSON 500 from the error handler.

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc