Node.js backend rules for AI coding agents
Most Node.js bugs an agent introduces are quiet ones: a promise nobody awaits, an async callback passed to forEach, a JSON.parse on user input that throws and takes the request with it. The Node.js backend rules target HTTP services and workers in TypeScript or JavaScript and are mostly about keeping async code honest and the event loop free.
They also cover the setup work agents skip: checking engines and .nvmrc before using a newer API, reading configuration from the environment once and validating it at startup, and keeping routes, services and data access in separate layers so services never touch req or res. Testing guidance is simple: use the runner the repo already has rather than adding a second one.
Folder: rules/node-backend/ · node-backend.mdc, CLAUDE.md, AGENTS.md
What the Node.js rules tell your agent
- Runtime and modulesCheck
enginesin package.json and.nvmrc. Target the active LTS line and do not use APIs newer than it. - Async and errorsEvery promise is awaited, returned, or explicitly handled. A floating promise hides failures and keeps the process alive.
- Event loop and performanceNever run CPU-heavy work (big JSON transforms, image work, bcrypt with high cost, sync crypto) on the request path. Move it to
worker_threadsor a job queue. - Configuration and securityRead config from environment variables once, validate it at startup with a schema, and export a typed config object. Fail fast on missing values.
- Project structureSplit routes (HTTP parsing, status codes), services (business rules) and data access (queries). Services never touch
reqorres.
First rule of each section in node-backend.mdc, quoted as shipped.
Mistakes these rules are written to prevent
forEachwith an async callback does not wait. Usefor...ofwithawait, orPromise.allovermap.JSON.parseon user input throws; catch it and return 400.res.sendafterres.json(or a missingreturnafter sending) causes "headers already sent".
Source: “Pitfalls to avoid”.
Checks the agent runs before it says done
Example commands from the Node.js file (swap in your repo's own):
- Lint:
npm run lint - Type check (TS):
npx tsc --noEmit - Tests:
npm test
Run lint, the type check if TypeScript, and the test suite. Start the server once and hit the changed route if you touched routing or middleware. Say which commands you ran and paste failures instead of summarizing them away.
Before and after: Node.js: async error handling in Express routes
Prompt card 05 · Stack: Node.js, Express 4 or 5 · Type: Review and fix · Best model: a frontier reasoning model
What the card is for
Find rejected promises that never reach the Express error handler, handlers that can send two responses, and error responses that leak stack traces. On Express 4 an unhandled rejection in an async handler leaves the request hanging until the client times out.
Worked example from the card
Before
router.post("/invites", async (req, res) => {
const body = JSON.parse(req.body.payload);
const team = await teams.get(body.teamId);
if (!team) res.status(404).json({ error: "team not found" });
mailer.send(body.email, inviteTemplate(team));
res.status(201).json({ ok: true });
});
app.use((err, req, res) => {
res.status(500).json({ error: err.message, stack: err.stack });
});
Expected AI output (abridged)
| POST /invites | 2 | JSON.parse throws on bad input -> unhandled rejection on Express 4,
| | | request hangs. Fix: validate and return 400.
| POST /invites | 4 | Missing return after 404 -> second response, ERR_HTTP_HEADERS_SENT.
| POST /invites | 5 | mailer.send not awaited -> failures are lost and the client gets 201
| | | even when no email was sent. Await it or enqueue a job.
| error handler | - | Three parameters, so Express treats it as normal middleware and never
| | | calls it for errors. Also leaks message and stack.
Recommendation: on Express 4, add try/catch with next(err) per handler now; plan the
Express 5 upgrade to remove the boilerplate.
After
const InviteBody = z.object({ teamId: z.string().uuid(), email: z.string().email() });
router.post("/invites", async (req, res, next) => {
try {
const parsed = InviteBody.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body" });
const team = await teams.get(parsed.data.teamId);
if (!team) return res.status(404).json({ error: "team_not_found" });
await jobs.enqueue("send-invite", { teamId: team.id, email: parsed.data.email });
return res.status(201).json({ ok: true });
} catch (err) {
return next(err);
}
});
app.use((err, req, res, next) => {
if (err instanceof NotFoundError) return res.status(404).json({ error: err.code });
req.log.error({ err }, "unhandled error");
return res.status(500).json({ error: "internal_error" });
});
Excerpt from card 05; the prompt itself is in the pack.
More Node.js prompt cards in the pack
- Card 06: Node.js: find work that blocks the event loop
- Card 07: Node.js: bounded concurrency for batch I/O
Using these rules in Cursor, Claude Code and AGENTS.md tools
Cursor
Put _base.mdc and node-backend.mdc in .cursor/rules/. Globs: src/**/*.ts, src/**/*.js, src/**/*.mjs, server/**/*.ts, package.json.
Claude Code
Root CLAUDE.md, base first. Path-scoped variant .claude/rules/node-backend.md for src/**/*.{ts,js,mjs}, server/**/*.ts; PostToolUse hook: npx prettier --write <file>.
AGENTS.md tools (Codex, Gemini CLI, Aider)
Base then stack in one AGENTS.md. Gemini CLI needs context.fileName; Aider needs --read AGENTS.md. Put this file in services/api/ in a monorepo, or the repo root. Agents use the AGENTS.md nearest to the file being edited, so a nested copy overrides a root one for that subtree.