Backend Node + Express Development Skill
Expert guidance for building production-grade, API-only Node.js + Express backends. This skill ensures every new backend project starts with a clean architecture, consistent patterns, and security-first defaults — whether in JavaScript or TypeScript.
Instructions
Step 1: Confirm Project Basics
Before writing any code, clarify with the user:
- Language: JavaScript or TypeScript?
- Database: PostgreSQL, MySQL, MongoDB, or other?
- ORM/Query builder: Prisma, Knex, Mongoose, raw driver?
- Auth strategy: JWT or session-based?
If the user doesn't specify, default to: TypeScript + PostgreSQL + Prisma + JWT.
Node Version: Require Node 24 LTS (Active LTS), preferably >=24.10.0 for non-experimental env-file support. Use node --watch for dev restarts and --env-file-if-exists for env loading — no nodemon or dotenv needed. For the full version/stability table and lifecycle guidance, see node-version-guide.md.
Pin the Node version in .nvmrc — e.g. 24 or a specific 24.x.y (>=24.10.0 recommended).
Step 2: Scaffold the Folder Structure
Always generate this folder structure. Adapt file extensions based on JS vs TS.
.
├─ src/
│ ├─ app/
│ │ ├─ app.(js|ts) # Express app: middleware stack + route mounting
│ │ └─ server.(js|ts) # HTTP server start + graceful shutdown
│ ├─ config/
│ │ ├─ env.(js|ts) # Env var parsing + validation (fail fast on boot)
│ │ └─ index.(js|ts) # Normalized config object (single source of truth)
│ ├─ routes/
│ │ ├─ index.(js|ts) # Mount all versioned routers (e.g. /api/v1)
│ │ └─ v1/
│ │ ├─ health.routes.(js|ts) # Health + readiness endpoints
│ │ └─ users.routes.(js|ts) # Example resource module
│ ├─ controllers/
│ │ └─ users.controller.(js|ts) # HTTP glue only (thin — no business logic)
│ ├─ services/
│ │ └─ users.service.(js|ts) # All business logic and use-cases
│ ├─ repositories/
│ │ └─ users.repo.(js|ts) # DB access only (queries, ORM calls)
│ ├─ db/
│ │ ├─ client.(js|ts) # DB connection pool / Prisma client
│ │ ├─ migrations/ # Database migration files
│ │ └─ seed/ # Seed scripts for dev/test data
│ ├─ middlewares/
│ │ ├─ auth.(js|ts) # Authentication middleware
│ │ ├─ error-handler.(js|ts) # Global error handler (MUST be last middleware)
│ │ ├─ rate-limit.(js|ts) # Rate limiting (global + per-route)
│ │ └─ request-id.(js|ts) # Adds req.id for log correlation
│ ├─ validators/
│ │ └─ users.schema.(js|ts) # Zod or Joi schemas for request validation
│ ├─ errors/
│ │ ├─ AppError.(js|ts) # Custom error base class with status + code
│ │ └─ error-codes.(js|ts) # Stable, documented error codes map
│ ├─ logging/
│ │ └─ logger.(js|ts) # Structured logger (pino/winston) + secret redaction
│ ├─ utils/
│ │ ├─ async-handler.(js|ts) # Wraps async controllers (no try/catch boilerplate)
│ │ └─ http-response.(js|ts) # Standardized success/error response helpers
│ └─ types/ # TS only: shared interfaces and type definitions
│
├─ tests/
│ ├─ unit/ # Service + utility unit tests
│ ├─ integration/ # Route + DB integration tests
│ └─ e2e/ # Full flow end-to-end tests
│
├─ docs/
│ ├─ openapi.yaml # OpenAPI spec (recommended)
│ └─ runbook.md # Deploy, rollback, and ops notes
│
├─ scripts/ # One-off scripts (backfills, maintenance)
├─ docker/ # Dockerfile, docker-compose, local infra
├─ .github/workflows/ # CI pipeline config
├─ .env.example # Documented env var template
├─ .editorconfig
├─ .nvmrc # Pin Node version
├─ .eslintrc.* / eslint.config.*
├─ .prettierrc / prettier.config.*
├─ README.md
└─ package.json
CRITICAL rules:
- One language per repo — never mix JS and TS source files.
- For TS repos, compile to a
dist/folder; never run raw.tsin production. - Every new feature module replicates the same pattern:
routes → controller → service → repository → validator. - Keep
types/for TS-only shared interfaces; omit for JS repos.
Step 3: Follow the Request Flow
All requests MUST flow through these layers in order:
Route → Controller → Service → Repository
| Layer | Responsibility | Rules |
|---|---|---|
| Routes | Define URL paths + HTTP methods | Only call controllers. No logic. |
| Controllers | Parse/validate input, call service, format response | THIN — no business logic, no DB calls |
| Services | Business logic, permissions, orchestration | May call multiple repos. Owns transactions. |
| Repositories | Database queries only | Return domain objects, not raw DB rows |
NEVER skip layers. Controllers must NOT call repositories directly. Services must NOT write raw SQL.
Step 4: Apply Security Defaults
Every backend MUST include these from day one:
- Helmet — secure HTTP headers
- CORS — explicit allowlist (never use
*in production) - Rate limiting — global + stricter on auth routes (login, register)
- Body size limits — prevent payload abuse
- Input validation — validate params, query, and body on every endpoint using Zod or Joi
- Password hashing — bcrypt or argon2 (NEVER store plaintext passwords)
- Secret redaction — never log tokens, passwords, or API keys
- Auth consistency — pick JWT or sessions and stick with one approach
Step 5: Implement the Middleware Stack
Mount middleware in this exact order:
request-id— assigns unique ID to every request- Logger middleware — logs request start/end with request ID
helmet— security headerscors— cross-origin policy- Body parsers (
express.json(),express.urlencoded()) + size limits - Rate limiter
- Auth middleware — only on protected routes
- Routes — mount
/api/v1/* - 404 handler — catch unmatched routes
- Global error handler — MUST be last
Step 6: Standardize API Responses
Use a consistent response shape across ALL endpoints:
// Success
{
"success": true,
"data": { ... }
}
// Error
{
"success": false,
"error": {
"code": "USER_NOT_FOUND",
"message": "No user found with the given ID.",
"details": null
}
}
Use correct HTTP status codes:
400— validation errors401— unauthenticated403— forbidden (authenticated but not authorized)404— resource not found409— conflict (duplicate, etc.)429— rate limited500+— server errors
Step 7: Set Up Error Handling
- Create a custom
AppErrorclass extendingErrorwithstatusCode,code, andisOperationalproperties. - Use an
async-handlerwrapper for all controller methods to catch rejected promises automatically. - The global error handler middleware (LAST in the stack) catches everything:
- Known
AppError→ return structured error with the appropriate status. - Unknown errors → log full stack trace, return generic 500 to client.
- NEVER leak stack traces or internal details in production responses.
- Known
- Define stable error codes in
error-codes.(js|ts)— clients depend on these, not message strings.
Step 8: Configure Logging and Observability
- Use a structured JSON logger (
pinorecommended,winstonacceptable). - Attach
req.id(request ID) to every log entry for correlation. - Include response headers:
x-request-id. - Add health endpoints:
GET /api/v1/health— always returns200if process is alive.GET /api/v1/ready— checks DB and external service connectivity.
- Redact any secrets, tokens, or passwords from all log output.
Step 9: Configure Environment and Scripts
Environment:
- Validate ALL env vars on boot using the config module — crash immediately if required vars are missing.
- Centralize env access in
src/config/index.(js|ts)— NEVER scatterprocess.env.*across the codebase. - Provide
.env.exampledocumenting every required and optional variable. - Do NOT install
dotenv— use Node's built-in env loading instead.
Package.json scripts depend on your Node version. See scripts-and-env.md for the full 3-tier guide (Tier A: Node >=24.10.0 non-experimental, Tier B: Node 22.9+ experimental flags, Tier C: Node 20.12+ programmatic loading).
Quick reference (Tier A / recommended):
- JS dev:
node --watch --env-file-if-exists=.env src/app/server.js - TS dev:
tsc -win terminal 1,node --watch --env-file-if-exists=.env dist/app/server.jsin terminal 2
Always include: lint, format, test, test:watch, migrate, seed
IMPORTANT: Do NOT add nodemon, dotenv, or tsx as dependencies.
Step 10: Database Best Practices
- ALWAYS use migrations — never modify schema by hand in production.
- Use connection pooling with proper timeout configuration.
- Wrap multi-step writes in transactions.
- Index intentionally and monitor slow queries.
- Keep ALL database logic inside
repositories/— services never write raw queries. - Seed scripts go in
src/db/seed/for reproducible dev/test data.
Step 11: Testing Strategy
- Unit tests (
tests/unit/) — test services, utilities, and pure logic in isolation. - Integration tests (
tests/integration/) — test routes with a real DB (use Docker). - E2E tests (
tests/e2e/) — test complete user flows end-to-end. - Use
supertestfor HTTP assertions in integration tests. - Use factories/fixtures for predictable, reproducible test data.
- Testing framework:
vitest(preferred) orjest. - Zero-dependency alternative: Node's built-in test runner (
node --test) is stable since Node 20 and supportsdescribe/itand assertions. Module mocking requires--experimental-test-module-mocks; coverage via--experimental-test-coverage. Vitest and Jest remain excellent for richer ecosystems.
Step 12: Production Readiness
Before deploying, ensure:
- Graceful shutdown — stop accepting requests, drain connections, close DB pool, stop background workers.
- Docker — multi-stage build, non-root user, healthcheck wired to
/api/v1/health. - Stateless — no in-memory state; use external stores (Redis, DB) for sessions/cache.
- 12-Factor — log to stdout/stderr, config via env vars, one codebase per deploy.
- CI pipeline — lint → test → build (TS) → optional security scan → optional integration tests with Dockerized DB.
- Secrets — use a secret manager in production; never commit
.envfiles.
Step 13: Naming Conventions
| Type | Pattern | Example |
|---|---|---|
| Routes | *.routes.(js|ts) | users.routes.ts |
| Controllers | *.controller.(js|ts) | users.controller.ts |
| Services | *.service.(js|ts) | users.service.ts |
| Repositories | *.repo.(js|ts) | users.repo.ts |
| Validators | *.schema.(js|ts) | users.schema.ts |
| Errors | AppError.(js|ts) | AppError.ts |
| Config | Descriptive names | env.ts, index.ts |
Step 14: Recommended Dependencies
Runtime:
express— web frameworkhelmet— secure headerscors— cross-origin requestszodorjoi— request validationpinoorwinston— structured logging- DB driver:
pg,mysql2,mongoose, orprisma
NOT needed (handled by Node.js built-ins):
— usedotenv--env-file-if-exists(non-experimental in Node >=24.10.0) orprocess.loadEnvFile()(Node >=20.12.0)— usenodemonnode --watch(stable since Node >=20.13.0)— usetsxtsc -w+node --watch(or native TS in Node >=22.18.0)
Dev tooling:
eslint+prettier— code qualityvitestorjest— test runner (ornode --testfor zero-dependency testing; coverage via--experimental-test-coverage)supertest— HTTP integration testinghusky+lint-staged— git hooks (optional but recommended)typescript— compiler (TS repos only)
Examples
Example 1: New Backend Project
User says: "Create a new Express API for a task management app"
Actions:
- Confirm: TypeScript, PostgreSQL, Prisma, JWT (or ask user preference)
- Scaffold the full folder structure with
tasksas the first resource module - Create
routes/v1/tasks.routes.ts,controllers/tasks.controller.ts,services/tasks.service.ts,repositories/tasks.repo.ts,validators/tasks.schema.ts - Set up middleware stack in correct order
- Configure env validation, logger, error handling
- Add health endpoints
- Create
package.jsonwithnode --watchdev script and--env-file-if-existsfor env loading — no nodemon or dotenv - Provide
.env.example - Pin
24in.nvmrc(Node 24 LTS)
Example 2: Adding a New Feature Module
User says: "Add an orders feature to my API"
Actions:
- Create the full module following the established pattern:
routes/v1/orders.routes.(js|ts)controllers/orders.controller.(js|ts)services/orders.service.(js|ts)repositories/orders.repo.(js|ts)validators/orders.schema.(js|ts)
- Add migration for the orders table
- Mount routes in
routes/v1/index - Follow existing naming and response conventions
Example 3: Backend Code Review
User says: "Review my Express backend structure"
Actions:
- Check folder structure against the recommended layout
- Verify the request flow (Route → Controller → Service → Repository)
- Check for security defaults (helmet, cors, rate limiting, validation)
- Verify error handling pattern (AppError, global handler, async wrapper)
- Review middleware ordering
- Flag any anti-patterns (business logic in controllers, raw SQL in services, scattered env access)
Reference Files
- Node version details: See node-version-guide.md for the full version/stability table, lifecycle guidance, watch flags, and native TypeScript details.
- Script tiers and env loading: See scripts-and-env.md for all 3 tiers of
package.jsonscripts based on Node version. - Troubleshooting: See troubleshooting.md for common anti-patterns and the rationale behind
--env-file-if-exists.