DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for Node.js: 6 Rules That Stop AI From Generating Bad Backend Code

CLAUDE.md for Node.js: 6 Rules That Stop AI From Generating Bad Backend Code

You ask Claude to "add a POST /users endpoint" and you get back:

  • A .then().catch() chain mixed with await in the same function
  • res.status(500).send(err.message) leaking stack traces in production
  • User.create({ ...req.body }) happily writing whatever the client sent
  • Inconsistent JSON shapes across routes (one returns { user }, another returns the user directly)
  • A try/catch that silently swallows the error and returns 200

The model didn't get worse. You just didn't tell it the rules.

A CLAUDE.md file at the root of your repo is the cheapest leverage you have. Claude Code reads it on every task. Any AI assistant that respects context files reads it too. You write the rules once and stop fighting the same fights every PR.

Below are 6 of the rules I drop into every Node.js / Express repo. Each one fixes a class of bug I see AI assistants generate by default.


Rule 1 — Always async/await. Never mix with callbacks or .then().

Why: AI tools default to whichever style was most common in their training data, and Node has 15 years of mixed-paradigm code. The result is functions that are half-callback, half-promise, with broken error propagation.

Bad (what Claude writes without rules):

app.post('/users', (req, res) => {
  validateUser(req.body)
    .then(async (data) => {
      const user = await User.create(data);
      res.json(user);
    })
    .catch((err) => res.status(500).send(err.message));
});
Enter fullscreen mode Exit fullscreen mode

Good:

app.post('/users', async (req, res, next) => {
  try {
    const data = await validateUser(req.body);
    const user = await User.create(data);
    res.status(201).json({ data: user });
  } catch (err) {
    next(err);
  }
});
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

All async code uses async/await. Never mix callback APIs with .then() chains in the same function. If a library is callback-only, wrap it once in util.promisify. Never write .then(...).catch(...) — use try/catch.


Rule 2 — One error-handling middleware. Controllers don't format errors.

Why: Every AI-generated controller wants to write its own res.status(500).json({ error: err.message }). Six months later you have 40 different error shapes and stack traces leaking to production.

Bad:

app.get('/users/:id', async (req, res) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) return res.status(404).send('not found');
    res.json(user);
  } catch (e) {
    res.status(500).json({ message: e.message, stack: e.stack });
  }
});
Enter fullscreen mode Exit fullscreen mode

Good:

// errors.js
class HttpError extends Error {
  constructor(status, code, message) { super(message); this.status = status; this.code = code; }
}

// route
app.get('/users/:id', async (req, res, next) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new HttpError(404, 'NOT_FOUND', 'User not found');
  res.json({ data: user });
});

// last middleware
app.use((err, req, res, _next) => {
  const status = err.status ?? 500;
  const body = { error: { code: err.code ?? 'INTERNAL', message: status === 500 ? 'Internal error' : err.message } };
  if (status === 500) req.log.error({ err }, 'unhandled');
  res.status(status).json(body);
});
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

All thrown errors must be caught by a single error-handling middleware mounted last. Controllers throw new HttpError(status, code, message) — they never call res.status(500).json(...) directly. Use express-async-errors so thrown async errors reach the middleware.


Rule 3 — Validate every input with Zod. Reject unknown fields.

Why: "Just trust req.body" is how mass-assignment bugs ship. The AI will happily generate User.create({ ...req.body }) and let the client set isAdmin: true.

Bad:

app.post('/users', async (req, res) => {
  const user = await User.create({ ...req.body });
  res.json(user);
});
Enter fullscreen mode Exit fullscreen mode

Good:

import { z } from 'zod';

const CreateUser = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
  password: z.string().min(12),
}).strict(); // reject unknown fields

const validate = (schema) => (req, _res, next) => {
  const parsed = schema.safeParse(req.body);
  if (!parsed.success) throw new HttpError(400, 'VALIDATION', parsed.error.message);
  req.validBody = parsed.data;
  next();
};

app.post('/users', validate(CreateUser), async (req, res) => {
  const user = await User.create(req.validBody);
  res.status(201).json({ data: user });
});
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Use Zod schemas to validate req.body, req.query, and req.params at the route boundary. Use .strict() on object schemas. Never spread req.body into a database write. Read fields only from the parsed, typed object.


Rule 4 — One response shape. Everywhere.

Why: Without a rule, the AI returns whatever shape feels natural for that endpoint. The frontend ends up with if (Array.isArray(res)) ... else if (res.data) ... else if (res.users) everywhere.

Bad:

app.get('/users', async (_req, res) => res.json(await User.find()));
app.get('/users/:id', async (req, res) => res.json({ user: await User.findById(req.params.id) }));
app.post('/login', async (_req, res) => res.json({ token: 'abc' }));
Enter fullscreen mode Exit fullscreen mode

Good — every success returns { data, meta? }, every error returns { error: { code, message } }:

app.get('/users', async (_req, res) => {
  const users = await User.find();
  res.json({ data: users, meta: { count: users.length } });
});

app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new HttpError(404, 'NOT_FOUND', 'User not found');
  res.json({ data: user });
});

app.post('/login', async (req, res) => {
  const token = await issueToken(req.validBody);
  res.json({ data: { token } });
});
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

All JSON responses follow one shape. Success: { "data": <payload>, "meta": <optional> }. Error: { "error": { "code": "STRING_CODE", "message": "Human readable", "details": <optional> } }. Never return bare arrays or strings.


Rule 5 — Use the right HTTP status code.

Why: Without this rule, every successful response is 200 and every failure is 500. You lose all the meaning HTTP gives you for free, and clients can't distinguish "validation failed" from "the database is on fire."

Bad:

app.post('/users', async (req, res) => {
  const user = await User.create(req.body); // returns 200, should be 201
  res.json(user);
});

app.delete('/users/:id', async (req, res) => {
  await User.deleteOne({ _id: req.params.id }); // returns 200, should be 204
  res.json({ ok: true });
});
Enter fullscreen mode Exit fullscreen mode

Good (and codify it):

Action Status
Successful GET / PUT / PATCH 200
Successful POST that creates 201
Successful DELETE with no body 204
Validation error 400
Missing / invalid auth 401
Authenticated but forbidden 403
Resource not found 404
Conflict (duplicate key) 409
Unexpected server error 500

Rule for CLAUDE.md:

200 for successful GET/PUT/PATCH. 201 for POST that creates a resource. 204 for DELETE with no body. 400 validation. 401 auth missing. 403 forbidden. 404 not found. 409 conflict. 500 only for unexpected server errors.


Rule 6 — No *Sync filesystem calls in the request path.

Why: AI-generated handlers love fs.readFileSync('./template.html') because it's two lines instead of three. It also blocks the event loop and tanks throughput under load.

Bad:

import fs from 'node:fs';

app.get('/welcome', (_req, res) => {
  const template = fs.readFileSync('./templates/welcome.html', 'utf8'); // blocks
  res.send(template);
});
Enter fullscreen mode Exit fullscreen mode

Good — use fs/promises, or load once at boot:

import { readFile } from 'node:fs/promises';

// load once at boot
const welcomeTemplate = await readFile('./templates/welcome.html', 'utf8');

app.get('/welcome', (_req, res) => {
  res.send(welcomeTemplate);
});
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Never call fs.readFileSync, fs.writeFileSync, or any *Sync API inside a route handler — they block the event loop. Use fs/promises. Synchronous calls are only allowed at boot or in CLI scripts.


Copy-paste this block into your CLAUDE.md

# CLAUDE.md — Node.js/Express Rules

## async/await
All async code uses async/await. Never mix callback APIs with .then() chains. Never use .then().catch() — use try/catch. Wrap callback-only libraries with util.promisify.

## Error handling
Mount one error-handling middleware last. Controllers throw new HttpError(status, code, message) — never res.status(500).json(...). Use express-async-errors for thrown async errors.

## Input validation
Validate req.body, req.query, req.params with Zod at the route boundary. Use .strict() so unknown fields fail. Never spread req.body into a DB write. Read fields only from parsed, typed objects.

## Response shape
Success: { "data": <payload>, "meta"?: <object> }
Error:   { "error": { "code": "STRING_CODE", "message": "...", "details"?: <any> } }
Never return bare arrays or strings.

## HTTP status codes
200 GET/PUT/PATCH. 201 POST that creates. 204 DELETE no body. 400 validation. 401 auth missing. 403 forbidden. 404 not found. 409 conflict. 500 unexpected only.

## No sync I/O in request path
Never use fs.*Sync inside a route handler. Use fs/promises. Sync calls allowed only at boot or in CLI scripts.
Enter fullscreen mode Exit fullscreen mode

Drop that into the root of your repo. You will stop seeing the same six bugs in every PR.


Get the full pack

These 6 rules are a free sample. The full CLAUDE.md Rules Pack has 50+ production-tested rules across React, TypeScript, Node.js, Python, Go, Rust, and Postgres — one-time payment, lifetime updates.

Get the full pack

Or grab the full Node.js sample as a Gist:

CLAUDE.md — Node.js/Express (20 rules, free)

Stop reviewing the same bad AI code. Write the rules once.

Top comments (0)