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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- express-api Installs to ~/.agents/skills/express-api/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install express-api The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project express-api Installs to .agents/skills/express-api/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/express-api.zipunzip -o express-api.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) express-api PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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.mdandpackage.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;
}
| Concern | Placement rule |
|---|---|
| Request id / logging | First, so every later log line carries the id |
| Security headers, CORS | Before routes; CORS before auth so preflights succeed |
| Body parsers | Global with a size limit, or per-route for webhooks needing the raw body |
| Rate limiting | Before expensive work; keyed per user or API key, not only per IP |
| Auth | Per router, so health checks and public routes stay open |
| 404 + error handler | Last |
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 4 | Express 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 throwsERR_HTTP_HEADERS_SENT;returnafter responding.- A raw-body webhook route must be registered before the global
express.json(), or useexpress.raw({ type: "application/json" })on that route only. app.listen()insideapp.tsmakes 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.yaml
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.md
# 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.