Node.js + Express AI Rules

Rules for Node.js + Express APIs in TypeScript: layered architecture, async error handling, Zod validation at boundaries, structured logging, and graceful shutdown.

TypeScriptExpress#nodejs#express#typescript#zodLast updated 2026-05-05
tune

Want to customize this rules file? Open the generator with this stack pre-loaded.

Open in generatorarrow_forward

Save at .cursor/rules/main.mdc

Node.js + Express + TypeScript

Project context

This is a Node.js HTTP service built with Express and TypeScript. We layer the code (routes → controllers → services → repositories), validate at boundaries with Zod, and treat errors as values where it makes the contract clearer.

Stack

  • Node.js 20+ (LTS)
  • Express 4.x (or 5.x once stable)
  • TypeScript 5+ in strict mode
  • Zod for validation
  • Drizzle or Prisma for DB access
  • pino for structured logging
  • vitest for tests, supertest for HTTP integration tests
  • pnpm

Folder structure

src/
  index.ts             — entrypoint: bootstrap + listen
  app.ts               — Express app factory (no listen)
  config.ts            — env config (read once, validated with Zod)
  routes/              — route definitions, one file per resource
  controllers/         — HTTP request → service call → response
  services/            — business logic, no Express types
  repos/               — DB queries, no business logic
  middleware/          — auth, logging, error handler
  schemas/             — Zod schemas for request/response shapes
  errors/              — typed error classes
  lib/                 — utilities
tests/
  integration/         — supertest against app
  unit/                — pure logic tests

The app factory in app.ts returns the configured Express instance without listening — makes integration tests easy.

Config

  • One config.ts at boot that reads process.env, parses with Zod, and exports a typed object
  • Fail fast on missing or malformed env vars
  • Never read process.env directly elsewhere
import { z } from 'zod'

const Schema = z.object({
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
})

export const config = Schema.parse(process.env)

Validation at boundaries

Validate every external input at its boundary:

const CreatePost = z.object({
  title: z.string().min(1).max(200),
  body: z.string().max(10_000),
})

router.post('/posts', async (req, res, next) => {
  try {
    const data = CreatePost.parse(req.body)
    const post = await postService.create(data)
    res.status(201).json(post)
  } catch (err) {
    next(err)
  }
})

Internal modules trust their inputs; only validate at HTTP boundaries, queue consumers, and external callbacks.

Error handling

  • One Express error-handling middleware at the bottom of the chain
  • Throw typed errors (NotFoundError, ValidationError, etc.) from services
  • Translate them to HTTP statuses in the error middleware
  • Always wrap async handlers — Express 4 doesn't catch promise rejections by default. Either try/catch + next(err) or use express-async-errors.
class NotFoundError extends Error {
  status = 404
  constructor(message: string) { super(message) }
}

Logging

  • pino: structured JSON, fast
  • One log per request via pino-http middleware
  • Don't log secrets / tokens / PII
  • Use child loggers (logger.child({ requestId })) to attach context

Database

  • One repo per resource (postRepo.findById, postRepo.create)
  • Repos return plain objects, not framework types
  • Services compose repos and call external APIs
  • Wrap multi-step writes in a transaction

Async patterns

  • async/await everywhere — never raw Promises with .then() chains
  • Promise.all for parallel I/O; Promise.allSettled when partial failure is acceptable
  • Cancel hanging requests with AbortController — pass signal to fetch and DB clients

Graceful shutdown

const server = app.listen(config.PORT)

const shutdown = async () => {
  server.close()
  await db.disconnect()
  process.exit(0)
}

process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)

Never let the process exit with active DB connections — leaks pool slots and may corrupt in-flight transactions.

Patterns to avoid

  • req.body without validation — always Zod-parse
  • Throwing strings or plain objects — always real Error instances
  • process.env outside config.ts — nowhere else
  • Mixing async/await and .then() in the same module
  • Sync DB calls (fs.readFileSync in a request handler)
  • Default export of the Express app from app.ts — named exports for grep-ability

Testing

  • Integration tests with supertest hitting app.ts
  • Unit tests for service-layer logic, no Express mocks needed
  • One test DB per process — wipe between tests via transactions or TRUNCATE
  • Mock external APIs at the HTTP layer (nock, msw); never mock your own modules

Tooling

  • pnpm devtsx watch src/index.ts
  • pnpm buildtsc
  • pnpm startnode dist/index.js
  • pnpm test — vitest
  • pnpm lint — ESLint
  • pnpm typechecktsc --noEmit

AI behavioral rules

  • Validate every external input with Zod at the HTTP boundary; never req.body as any
  • Throw real Error subclasses; never strings
  • Read process.env only inside config.ts
  • Never call next() and return res.json(...) in the same path — pick one
  • Use Promise.all for independent I/O; don't sequentially await unrelated calls
  • Set AbortController timeouts on every outbound fetch
  • Run lint, typecheck, and tests before declaring a task done

Frequently asked

How do I use this Express rules file with Cursor?

Pick "Cursor (.cursor/rules/*.mdc)" from the format dropdown above and click Copy. Save it at .cursor/rules/main.mdc in your project root and restart Cursor. The legacy .cursorrules format still works if you're on an older Cursor version — pick that option instead.

Can I use this with Claude Code (CLAUDE.md)?

Yes — pick "Claude Code (CLAUDE.md)" from the format dropdown above and copy. Save the file as CLAUDE.md at your repo root. Claude Code reads it automatically on every session. For monorepos, you can also drop nested CLAUDE.md files in subdirectories — Claude merges them when working in those paths.

Where exactly do I put this file?

It depends on the AI tool. Cursor reads .cursorrules or .cursor/rules/*.mdc at the project root. Claude reads CLAUDE.md at the project root. Copilot reads .github/copilot-instructions.md. The "Save at" path under each format in the dropdown shows the exact location for the format you picked.

Can I customize these Express rules for my project?

Yes — that's what the generator is for. Click "Open in generator" above and the wizard loads with this stack's defaults pre-selected. Toggle on or off the conventions you want, then re-export in your AI tool's format.

Will using this rules file slow down my AI tool?

No. Rules files count toward the model's context window but not toward latency in any noticeable way. The file is loaded once per session, not per token. The library files target 250–400 lines, well within every tool's recommended budget.

Should I commit this file to git?

Yes. The rules file is project documentation that benefits every developer using the AI tool. Commit it. The exception is personal-global settings (e.g. ~/.claude/CLAUDE.md) which are user-scoped and stay out of the repo.

Related stacks