Cursor Rules for Express.js: The Complete Guide to AI-Assisted Express Development
Express is the framework where the 20-line README example has been copy-pasted into production more times than anyone wants to admit. The server starts. The route returns JSON. The curl in the acceptance criteria works. What the README never shows is the bodyparser that was registered after the route that needs it and silently fails to parse JSON for that one endpoint, the app.use(cors()) at the bottom of the file that never runs because it sits under the 404 handler, the res.send(err) in the catch block that ships the full stack trace to the client, the req.body.id read without validation that becomes an object and trips a WHERE id = [object Object] SQL string, the async handler whose rejection disappears into the void because Express 4 doesn't forward unhandled promise rejections to next(), or the JWT middleware that trusts any header named authorization without ever checking the signature. Three of those four bugs are in the same tutorial that got 40k stars.
Then you add an AI assistant.
Cursor and Claude Code were trained on a decade of Express examples, most of them predating async/await, most of them using bodyParser as a top-level import even though it's baked in since 4.16, most of them with app.use(cors()) with no origin restriction, app.get('/user/:id', (req, res) => db.query('SELECT * FROM users WHERE id=' + req.params.id)) and a comment that says "TODO: sanitize later," and a "simple error handler" that is app.use((err, req, res, next) => res.status(500).send(err.message)). Ask for "a simple API with JWT auth," and you get a jsonwebtoken verify call in every handler, no typed req.user, an error middleware that swallows ZodError as 500, and a CORS config that allows * with credentials enabled — which the browser refuses but which makes you think it's working because the server doesn't care.
The fix is .cursorrules — one file in the repo that tells the AI what idiomatic, modern Express looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.
How Cursor Rules Work for Express Projects
Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended for anything beyond a single service). For Express I recommend modular rules so the HTTP conventions don't bleed into background-worker code in the same repo and so the auth rules apply only to files that declare they need them:
.cursor/rules/
express-core.mdc # middleware order, router composition
express-async.mdc # asyncHandler, error propagation
express-errors.mdc # single error funnel, AppError, mapping
express-validation.mdc # Zod at boundary, req typing
express-auth.mdc # auth middleware, req.user typing
express-security.mdc # helmet, rate limit, CORS, trust proxy
express-types.mdc # typed handlers, declaration merging
Frontmatter controls activation: globs: ["src/**/*.{ts,js}"] with alwaysApply: false. Now the rules.
Rule 1: Middleware Order Is the Contract — Register Once, Register In Order
Express is a pipeline. The order in which app.use(...) is called is the order middleware runs, full stop. The most common AI failure is to register middleware in the order the developer thought of them rather than the order the request needs. Cursor writes app.use(cors()); app.use(helmet()); app.use(express.json()); and then halfway down the file tacks on app.use(cookieParser()) below a route that reads cookies — so the cookie parser runs for every route after that line but not the one above it. Worse: the 404 handler registered in the middle of the file swallows every subsequent registration. Worse still: two app.use(express.json()) calls with different limit options, and the smaller one wins for some routes but not others depending on insertion order.
The rule:
Middleware is registered exactly once, in a single `buildApp()` function,
in this canonical order:
1. trust proxy (app.set('trust proxy', 1) behind a LB)
2. security headers (helmet)
3. request id / correlation id
4. request logging (morgan/pino-http) — AFTER the request id
5. CORS — BEFORE auth, so preflight doesn't hit the auth middleware
6. body parsers (express.json, express.urlencoded, cookieParser) with
explicit size limits
7. rate limiting
8. compression
9. health endpoints (/healthz, /readyz) — BEFORE auth
10. auth middleware (conditionally, per-router)
11. feature routers (mounted with app.use('/api/v1/orders', ...))
12. 404 fallback
13. central error middleware (4-arg signature)
No middleware is registered "near the code it cares about" in a feature
file. Feature files register ROUTES; middleware lives in buildApp.
Any middleware that needs to see all requests must be ABOVE the first
router. Any middleware that short-circuits (rate limit, auth) must be
BELOW the body parser only if it needs the body.
buildApp() is pure: given config + deps, returns an Express app. No
side effects at module load. Test builds the same app.
Before — order implicit, parsers after a route that needs them, 404 swallows later routes:
const app = express();
app.use(cors()); // allows any origin with credentials implied
app.get('/health', (_, res) => res.send('ok'));
app.use((req, res, next) => { // 404
res.status(404).send('not found');
});
app.use(express.json()); // never runs — 404 above swallows it
app.use('/api/orders', ordersRouter); // body parsing gone
Every POST body is undefined and the team spends a morning debugging before finding the misordered middleware.
After — single buildApp with canonical order, feature files expose routers only:
export function buildApp(deps: AppDeps): Express {
const app = express();
app.set('trust proxy', 1);
app.use(helmet());
app.use(requestContext);
app.use(pinoHttp({ logger: deps.logger, genReqId: req => req.id }));
app.use(cors({ origin: deps.config.CORS_ORIGINS, credentials: true }));
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: false, limit: '100kb' }));
app.use(cookieParser(deps.config.COOKIE_SECRET));
app.use(rateLimit({ windowMs: 60_000, max: 300 }));
app.use(compression());
app.get('/healthz', (_, res) => res.json({ status: 'ok' }));
app.get('/readyz', readyHandler(deps));
app.use('/api/v1/orders', authRequired, ordersRouter(deps));
app.use('/api/v1/public', publicRouter(deps));
app.use(notFoundHandler);
app.use(errorMiddleware(deps.logger));
return app;
}
One file, one order. Feature files export ordersRouter(deps) and never touch app.use. Tests call buildApp(testDeps).
Rule 2: Async Handlers — Every Route Wrapped, Never Fire-And-Forget
Express 4's router does not know what to do with a returned Promise. If an async handler throws, the rejection floats up as an unhandledRejection and never reaches your error middleware. The result is a route that hangs: the client waits until the request times out because no one ever called res.send or next(err). Cursor writes app.get('/users', async (req, res) => { const rows = await db.query(...); res.json(rows); }) and the happy path works — but the first time db.query rejects, the route stalls and nothing is logged except "connection closed by peer" at the load balancer. Express 5 fixes this natively; Express 4 does not, and most of the codebase you inherit is still 4.
The rule:
If the codebase is Express 4: every async route is wrapped with
`asyncHandler(fn)` that does `Promise.resolve(fn(req, res, next)).catch(next)`.
No raw `async (req, res) => ...` passed to `app.get`/`router.post`.
ESLint custom rule or the `express-async-errors` package catches misses.
If the codebase is Express 5: unwrapped async handlers are fine —
rejections reach next(). But maintain `asyncHandler` usage for clarity
and for any sync callback callers.
No middleware does `someAsyncThing()` without awaiting. Fire-and-forget
inside middleware rejects into unhandledRejection.
Never call `res.send` / `res.json` / `res.end` inside a catch that
intends to forward the error — forwarding means `next(err)` only,
never `res.status(500).send(...)` and then `next(err)`.
Before — bare async handler, hung request on throw:
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id); // rejects
res.json(user); // never reached — no response ever sent
});
The client times out at 30s. No log, no alert, no error middleware invocation.
After — asyncHandler, typed error thrown, error middleware handles it:
export const asyncHandler =
<R extends Request = Request>(fn: (req: R, res: Response, next: NextFunction) => Promise<unknown>) =>
(req: Request, res: Response, next: NextFunction) =>
Promise.resolve(fn(req as R, res, next)).catch(next);
router.get('/users/:id', asyncHandler(async (req, res) => {
const user = await deps.userService.findById(req.params.id);
if (!user) throw new NotFoundError('user');
res.json(user);
}));
Any throw reaches next(err). The central error middleware decides the status and shape. The client gets a real response, every time.
Rule 3: Central Error Handling — One Funnel, Typed Errors, No Stack Trace Leaks
The canonical Express error middleware in tutorials is app.use((err, req, res, next) => res.status(500).send(err.message)). It is wrong in three ways: it leaks internal error messages to clients (duplicate key violates unique constraint "users_email_key" is a gift to attackers), it returns 500 for every error including validation errors that should be 400, and it has no shape — every error is a free-text string. Cursor writes this version by default because it's what Stack Overflow shows. The alternative is a typed AppError hierarchy plus a single error middleware that maps each error class to an HTTP status and a stable JSON shape, logs unhandled errors with request context, and never lets an Error.stack or a Postgres internal reach the response body.
The rule:
All errors thrown in handlers are either:
- an AppError subclass (NotFoundError, ValidationError, ForbiddenError,
ConflictError, UnauthorizedError, RateLimitedError)
- a ZodError (caught specifically, mapped to 400)
- a 3rd-party library error (caught, wrapped in AppError with cause)
AppError has: code (stable string), statusCode, message (safe for client),
cause (original error). Only the `code` and `message` are in the response.
Single error middleware:
1. ZodError -> 400 with issues array
2. AppError -> err.statusCode with { code, message }
3. Everything else -> log at error level with full context, respond
500 with `{ code: 'INTERNAL', message: 'Internal error' }`.
Never include err.stack or err.message in the response.
Every log line includes reqId from requestContext. Handlers never call
res.status(>=400) directly — they throw. Keeps the mapping in one place.
404 handler is a separate middleware BEFORE the error middleware that
throws NotFoundError('route'). Keeps all 404 logic consistent.
Before — leaks stack trace, maps everything to 500:
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: err.message, stack: err.stack });
});
A Postgres duplicate key error becomes 500 {"error":"duplicate key value violates unique constraint..."} — leaks the table name, the column, and the fact that you're on Postgres.
After — typed hierarchy, single funnel, safe client output:
export abstract class AppError extends Error {
abstract readonly code: string;
abstract readonly statusCode: number;
constructor(message: string, public readonly cause?: unknown) { super(message); }
}
export class NotFoundError extends AppError { code = 'NOT_FOUND'; statusCode = 404; }
export class ValidationError extends AppError { code = 'VALIDATION_ERROR'; statusCode = 400; }
export class ConflictError extends AppError { code = 'CONFLICT'; statusCode = 409; }
export class UnauthorizedError extends AppError { code = 'UNAUTHORIZED'; statusCode = 401; }
export const errorMiddleware = (logger: Logger) =>
(err: unknown, req: Request, res: Response, _next: NextFunction) => {
if (err instanceof ZodError) {
return res.status(400).json({
code: 'VALIDATION_ERROR',
issues: err.issues.map(i => ({ path: i.path.join('.'), message: i.message })),
});
}
if (err instanceof AppError) {
logger.warn({ err, reqId: req.id }, 'handled error');
return res.status(err.statusCode).json({ code: err.code, message: err.message });
}
logger.error({ err, reqId: req.id }, 'unhandled error');
res.status(500).json({ code: 'INTERNAL', message: 'Internal error' });
};
Client sees { code: 'CONFLICT', message: 'email already in use' }, not the Postgres constraint name. Error taxonomy is stable and testable.
Rule 4: Validate At The Boundary — Zod Parses req, Handler Receives Typed Data
req.body, req.query, and req.params are typed any by Express. Cursor writes const { email, password } = req.body; without validation; a client sends { email: 123 }, bcrypt.hash(123) throws a type error deep in a library, and the error middleware returns 500 for what should have been a 400. req.query.limit is always a string — parseInt(req.query.limit) for ?limit=abc yields NaN, the pagination returns every row, and the response size OOM-kills the pod. The fix is a schema validator at the boundary that both checks the shape and narrows the type for the rest of the handler.
The rule:
Every handler that reads req.body, req.query, or req.params calls a
Zod schema's .parse() against those fields BEFORE touching them.
Throwing ZodError -> 400 via the error middleware.
Schemas live in `src/schemas/*.ts`, imported by handler + service +
any client code. One source of truth per contract.
Prefer a reusable `validate({ body?, query?, params? })` middleware
that parses in one place and attaches the result to `res.locals.input`
with a typed `res.locals`. Handlers read `res.locals.input`, never
`req.body`.
Refinements live in the schema, not the handler: z.string().email().
toLowerCase().trim(), z.coerce.number().int().min(1).max(100). Handler
receives CLEAN, typed data.
Response bodies are validated in dev. `sendJson(res, schema, value)`
parses the response in development (catches contract breaks in tests)
and no-ops in production.
Before — untyped destructure, NaN pagination, runtime surprise:
router.get('/orders', async (req, res) => {
const limit = parseInt(req.query.limit) || 1000;
const orders = await db.orders.find({}).limit(limit);
res.json(orders);
});
?limit=abc produces NaN || 1000 = 1000. On a 10M-row table that's a 10M-row response.
After — Zod schema, typed res.locals, safe pagination:
const ListOrdersQuery = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string().optional(),
});
export const validate = <S extends ZodTypeAny>(schemas: { query?: S }) =>
(req: Request, res: Response, next: NextFunction) => {
if (schemas.query) res.locals.query = schemas.query.parse(req.query);
next();
};
router.get('/orders',
validate({ query: ListOrdersQuery }),
asyncHandler(async (req, res) => {
const { limit, cursor } = res.locals.query as z.infer<typeof ListOrdersQuery>;
const orders = await deps.orderService.list({ limit, cursor });
res.json(OrderListResponse.parse(orders));
})
);
?limit=abc returns 400 with { path: 'limit', message: 'Expected number' }. limit is bounded at 100. Handler never sees a malformed input.
Rule 5: Auth Middleware — Extract, Verify, Attach, Narrow
The most dangerous AI-generated Express auth is a copy of a blog post that reads req.headers.authorization.split(' ')[1] without checking for the header's existence, calls jwt.verify(token, secret) synchronously without a specific algorithm, and attaches the entire decoded payload to req.user. Three bugs: a missing header crashes with Cannot read properties of undefined; jwt.verify without an explicit algorithms: ['HS256'] is vulnerable to the alg: none attack (patched in modern libraries but worth being explicit); and attaching the raw JWT payload means any client-controlled field in the token now lives on req.user. The fix is a single auth middleware that extracts defensively, verifies with pinned algorithms, loads a fresh user record (or at least re-validates claims), and attaches a typed req.user via TypeScript declaration merging.
The rule:
Auth is a single middleware `authRequired(deps)` mounted per-router.
Public routers do not mount it. There is no "check token in every
handler" pattern.
Extraction is defensive: header optional, bearer prefix optional,
constant-time comparison is NOT needed for extraction but is needed for
any secret comparison in API-key auth.
jwt.verify: explicit algorithms, explicit issuer, explicit audience,
explicit clockTolerance. Never rely on defaults. Store JWT secret /
public key in config, rotated via kid claim + jwks cache.
After verification, load the user record (or cached projection) — DO NOT
trust the token payload for fields the user might have revoked. Attach
a typed UserContext to req.user via declaration merging:
declare global { namespace Express { interface Request { user?: UserContext } } }
authRequired throws UnauthorizedError on any failure. Don't distinguish
"no token" vs "invalid token" in the response — same 401 code,
auditable log.
Scoped/role checks are a separate middleware `requireScope('orders:write')`
mounted after authRequired, never inside handlers.
Before — undefined crash, alg confusion, raw payload leak:
function auth(req, res, next) {
const token = req.headers.authorization.split(' ')[1]; // crashes if header missing
const payload = jwt.verify(token, process.env.JWT_SECRET); // no algorithms pin
req.user = payload; // trusts every field
next();
}
app.use(auth);
No header = 500 instead of 401. Any claim the attacker adds to a forged token ends up in req.user.
After — defensive extraction, pinned algorithms, typed req.user:
declare global {
namespace Express { interface Request { user?: UserContext } }
}
export interface UserContext { id: string; email: string; scopes: readonly string[] }
export const authRequired = (deps: AuthDeps): RequestHandler =>
asyncHandler(async (req, _res, next) => {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) throw new UnauthorizedError('missing token');
const token = header.slice('Bearer '.length);
let claims: JwtClaims;
try {
claims = jwt.verify(token, deps.config.JWT_PUBLIC_KEY, {
algorithms: ['RS256'],
issuer: deps.config.JWT_ISSUER,
audience: deps.config.JWT_AUDIENCE,
clockTolerance: 5,
}) as JwtClaims;
} catch { throw new UnauthorizedError('invalid token'); }
const user = await deps.userService.findActive(claims.sub);
if (!user) throw new UnauthorizedError('user not found');
req.user = { id: user.id, email: user.email, scopes: user.scopes };
next();
});
export const requireScope = (scope: string): RequestHandler => (req, _res, next) => {
if (!req.user?.scopes.includes(scope)) throw new ForbiddenError('missing scope');
next();
};
Missing or malformed tokens return a stable 401. req.user is always typed inside protected routers. Scope checks are composable.
Rule 6: Router Composition — Feature-Owned Routers, Dependency-Injected, Not File-Level Side Effects
The usual pattern is const router = express.Router(); router.get('/', handler); module.exports = router; with the handler pulling const db = require('../db') at the top of the file. The handler is impossible to test without mocking the db module, the file has implicit side effects on load, and feature-level wiring is scattered across the repo. Cursor writes this shape by default. The alternative is a router-as-factory: export function ordersRouter(deps): Router that receives injected services and returns a configured router. Composition root assembles them.
The rule:
Every feature exports a router factory: `export function xRouter(deps):
Router`. No module-level router that imports services via require().
Routers don't register middleware that should be global (logging,
CORS). They DO register feature-scoped middleware
(validate(Schema), requireScope('orders:write')).
Route handlers are thin: parse input (already done by validate),
call service, format response. No business logic inline, no DB
calls, no fetch.
One file per router, colocated with schemas and the service it calls:
src/features/orders/
orders.router.ts — router factory
orders.service.ts — business logic
orders.schemas.ts — Zod schemas
orders.types.ts — DTOs
Path prefixes live in buildApp (`app.use('/api/v1/orders',
ordersRouter(deps))`), not inside the router. Makes versioning and
mounting flexible.
No direct `express.Router()` inside a handler or conditional. Routers
are built at app boot, exactly once.
Before — module-level db import, untestable handler:
// orders.js
const db = require('../db');
const router = require('express').Router();
router.get('/', async (req, res) => {
const rows = await db.query('SELECT * FROM orders');
res.json(rows);
});
module.exports = router;
Test needs jest.mock('../db'). Production uses real db by accident in the test because the mock didn't apply in time.
After — factory, injected service, testable in isolation:
export function ordersRouter(deps: { orderService: OrderService }): Router {
const router = Router();
router.get('/',
validate({ query: ListOrdersQuery }),
asyncHandler(async (req, res) => {
const input = res.locals.query as ListOrdersQuery;
const orders = await deps.orderService.list(input, req.user!);
sendJson(res, OrderListResponse, orders);
}));
router.post('/',
requireScope('orders:write'),
validate({ body: CreateOrderSchema }),
asyncHandler(async (req, res) => {
const input = res.locals.body as CreateOrderInput;
const order = await deps.orderService.create(input, req.user!);
res.status(201);
sendJson(res, OrderResponse, order);
}));
return router;
}
Test constructs a fake OrderService, passes it to ordersRouter({ orderService: fake }), and asserts on supertest calls. No module mocking.
Rule 7: Security Defaults That Actually Fire — Helmet, Rate Limit, CORS, Trust Proxy, JSON Limits
The default Express app is a security nightmare: no CSP, no Referrer-Policy, no X-Content-Type-Options, unlimited request body size (1GB bodies are a perfectly valid POST), CORS wide open or, worse, * with credentials: true which the browser rejects silently so the dev assumes CORS is working, and req.ip returning the load-balancer IP because trust proxy was never set. Cursor generates a working-but-exposed app because the minimal tutorial example doesn't include any of these. The rule is to make each one explicit and to verify the config with integration tests that assert headers and rejected payloads.
The rule:
Required security middleware, registered in buildApp in this order:
- app.set('trust proxy', 1) when behind a known LB. Without this
req.ip is wrong, rate limit keys are wrong.
- helmet() with explicit CSP tailored to the app. No `helmet.contentSecurityPolicy(false)`.
- CORS with explicit origin (function or array), credentials:true only
when needed. Never `origin: '*'` with credentials.
- express.json({ limit }) — limit from config, default 100kb. No
unbounded bodies.
- express-rate-limit with keyGenerator that uses req.ip (requires
trust proxy) plus user id when available. Different limits for
auth vs anonymous.
- HTTPS redirect (or HSTS via helmet) in production.
- Cookie flags: httpOnly, sameSite: 'lax' | 'strict', secure in prod,
signed cookies with COOKIE_SECRET from config.
No `app.disable('x-powered-by')` as a "security measure" when helmet
handles it. Don't double-register.
Integration test asserts headers: `expect(res.headers['x-content-type-options']).toBe('nosniff')`. If a dev removes helmet, the test fails.
`req.body` over the JSON limit is auto-400 by express.json() with
`type: "entity.too.large"`. The error middleware maps it to
{ code: 'PAYLOAD_TOO_LARGE', statusCode: 413 }.
Before — wide-open CORS, no body limit, trust proxy missing:
app.use(cors({ origin: '*', credentials: true })); // browser rejects silently
app.use(express.json()); // 100mb default? nope — 100kb, but still no explicit config
// rate limiter keyed on req.ip — which is the LB's IP, so one-bucket-for-everyone
app.use(rateLimit({ max: 100 }));
The CORS config doesn't actually allow credentials (browser spec). Rate limit is global because req.ip is the LB. No body limit enforcement assumption means a library upgrade could change the default.
After — explicit, verified, tested:
app.set('trust proxy', 1);
app.use(helmet({
contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], imgSrc: ["'self'", 'data:'] } },
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
}));
app.use(cors({
origin: (origin, cb) => cb(null, !origin || deps.config.CORS_ORIGINS.includes(origin)),
credentials: true,
maxAge: 600,
}));
app.use(express.json({ limit: deps.config.JSON_LIMIT, type: 'application/json' }));
app.use(cookieParser(deps.config.COOKIE_SECRET));
app.use(rateLimit({
windowMs: 60_000,
max: (req) => (req.user ? 600 : 60),
keyGenerator: (req) => req.user?.id ?? req.ip!,
standardHeaders: true,
legacyHeaders: false,
}));
Integration tests assert x-content-type-options: nosniff, reject an unlisted Origin, and expect(413) for an oversize body.
Rule 8: TypeScript All The Way — Typed Handlers, res.locals, Declaration Merging
Express types from @types/express leave req.body, req.params, req.query, res.locals, and req.user wide open. Cursor writes (req: Request, res: Response) => ... and then accesses req.body.email as string — a cast is not a validation. The discipline is to type res.locals through the validation middleware, extend Express.Request via declaration merging for user and id, and type handlers with generics so the inferred parameter and response shapes bubble up. When the schema changes, the handler fails to compile — the intended outcome.
The rule:
Typed Request generics: `Request<Params, ResBody, ReqBody, Query>`.
Prefer a helper alias `TypedHandler<P, B, Q, R>` that wires them all.
validate() middleware writes to res.locals with a strict type:
interface LocalInputs<B, Q, P> { body?: B; query?: Q; params?: P }
res.locals as unknown as LocalInputs<...>
Handler retrieves via a typed helper `inputs(res)` returning the strict
shape.
Declaration merging for cross-cutting state:
declare global { namespace Express {
interface Request { id: string; user?: UserContext }
interface Locals { /* explicit per-route types set by validate */ }
}}
No `as any`, no `// @ts-expect-error` to sidestep type errors in
handlers. If the types are wrong, the schema or the declaration
merging is wrong — fix it there.
Response types are enforced by `sendJson(res, schema, value)` which
is typed so the value must satisfy z.infer<typeof schema>.
Router factories return `Router`, not `any`. Deps are typed by an
interface, not inferred from the composition root.
Before — casts everywhere, silent drift when the schema changes:
router.post('/', async (req: Request, res: Response) => {
const { email, total } = req.body as { email: string; total: number };
// schema later changes to `total: string` — this cast still "works"
const order = await service.create({ email, total });
res.json(order);
});
The cast survives refactors. Bugs appear at runtime.
After — inferred from schema, handler fails to compile on drift:
export type Inferred<S extends ZodTypeAny> = z.infer<S>;
export function inputs<B, Q, P>(res: Response): LocalInputs<B, Q, P> {
return res.locals as unknown as LocalInputs<B, Q, P>;
}
router.post('/',
validate({ body: CreateOrderSchema }),
asyncHandler(async (_req, res) => {
const { body } = inputs<Inferred<typeof CreateOrderSchema>, never, never>(res);
const order = await deps.orderService.create(body);
sendJson(res, OrderResponse, order);
})
);
If CreateOrderSchema changes, every handler that reads body gets a type error until it's updated. The cast route is gone.
The Complete .cursorrules File
Drop this in the repo root. Cursor and Claude Code both pick it up.
# Express.js — Production Patterns
## Middleware Order
- Register middleware exactly once, in buildApp(deps), in canonical
order: trust proxy -> helmet -> request-context -> logger -> CORS ->
body parsers -> rate limit -> compression -> health -> auth per
router -> feature routers -> 404 -> error middleware.
- Feature files export router factories. They never call app.use.
- buildApp is pure: (config, deps) -> Express app. No side effects at
module load.
## Async Handlers
- Express 4: wrap every async handler with asyncHandler(fn). Raw async
handlers are a lint error. Express 5: optional but encouraged.
- Never fire-and-forget in middleware. Await every promise.
- catch blocks that intend to forward MUST call next(err) and not
send a response.
## Central Error Handling
- All thrown errors are AppError subclasses, ZodError, or wrapped 3rd
party errors. Never throw strings.
- Single error middleware:
ZodError -> 400 { code: 'VALIDATION_ERROR', issues }
AppError -> err.statusCode { code, message }
else -> 500 { code: 'INTERNAL', message: 'Internal error' } and log full err
- Never include err.stack or err.message in 500 responses.
- Handlers never call res.status(>=400) directly — throw instead.
## Validation at the Boundary
- Every handler that reads req.body / req.query / req.params uses
validate({ body?, query?, params? }) with Zod schemas.
- Schemas live in src/schemas or per-feature *.schemas.ts.
- Handlers read res.locals.input (typed), never req.body directly.
- Refinements in the schema: trim, toLowerCase, z.coerce.number().
## Auth
- authRequired(deps) middleware mounted per-router. Public routers
omit it. No "check token inside the handler" pattern.
- jwt.verify with explicit algorithms, issuer, audience, clockTolerance.
- Load fresh user after verify — don't trust token claims for
authorization decisions.
- req.user typed via declaration merging. Scope checks via
requireScope('x:y') middleware.
## Security
- Required: trust proxy, helmet with explicit CSP, CORS with explicit
origins (no '*' + credentials), express.json with limit from config,
rate limit keyed on userId ?? req.ip.
- Cookies: httpOnly, sameSite, secure in prod, signed with
COOKIE_SECRET.
- Integration tests assert security headers and payload-size rejects.
## Router Composition
- Every feature: `ordersRouter(deps): Router`. No module-level
require of services.
- Thin handlers: parse input (already done by validate) -> call
service -> format response. No inline business logic or DB calls.
- Path prefix set in buildApp via app.use('/api/v1/orders',
ordersRouter(deps)), not inside the router.
## TypeScript
- Typed Request<Params, ResBody, ReqBody, Query> via asyncHandler
generics or a TypedHandler alias.
- validate() writes strictly-typed res.locals; inputs<T>(res) retrieves.
- Declaration merging for Express.Request { id; user }.
- Response validated by sendJson(res, schema, value) — value typed as
z.infer<typeof schema>.
- No `as any`, no @ts-expect-error to paper over handler types.
End-to-End Example: A POST /orders Endpoint
Without rules: inline db call, untyped body, swallowed error, wide CORS, console.log.
const db = require('./db');
const app = require('express')();
app.use(require('cors')());
app.use(require('express').json());
app.post('/orders', async (req, res) => {
try {
console.log('order', req.body);
const result = await db.query(
'INSERT INTO orders(user_id, total) VALUES($1,$2) RETURNING *',
[req.body.userId, req.body.total],
);
res.send(result.rows[0]);
} catch (e) {
res.status(500).send(e.message);
}
});
app.listen(3000);
With rules: typed router factory, Zod schema, auth per router, central error funnel.
// features/orders/orders.schemas.ts
export const CreateOrderSchema = z.object({
total: z.number().positive().max(1_000_000),
lineItems: z.array(z.object({ productId: z.string().uuid(), qty: z.number().int().positive() })).min(1),
});
export type CreateOrderInput = z.infer<typeof CreateOrderSchema>;
// features/orders/orders.router.ts
export function ordersRouter(deps: { orderService: OrderService }): Router {
const router = Router();
router.post('/',
requireScope('orders:write'),
validate({ body: CreateOrderSchema }),
asyncHandler(async (req, res) => {
const { body } = inputs<CreateOrderInput, never, never>(res);
const order = await deps.orderService.create(body, req.user!);
logger.info({ orderId: order.id, userId: req.user!.id }, 'order: created');
res.status(201);
sendJson(res, OrderResponse, order);
}));
return router;
}
// app.ts (composition root excerpt)
app.use('/api/v1/orders', authRequired(deps), ordersRouter(deps));
Malformed body → 400 with a Zod issue list. Missing token → 401. Missing scope → 403. Service failure → 500 with stable code and no stack leak. Log line has reqId, orderId, userId. The next prompt you write at this repo will produce code that already fits.
Get the Full Pack
These eight rules cover the Express patterns where AI assistants consistently reach for the wrong tutorial. Drop them into .cursorrules and the next handler you generate will be wrapped, validated, authed, logged, and typed — without having to re-prompt.
If you want the expanded pack — these eight plus rules for Fastify migration paths, multer/file-upload safety, SSE streaming handlers, websocket integration via socket.io/ws, Prisma transactional services, OpenAPI generation from Zod, testing with supertest + testcontainers, rate-limit with Redis, and observability via OpenTelemetry — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Express you would actually merge.
Top comments (0)