Framework · Node.js 24 LTS · Fastify 5.9 · TypeScript 6.0 · Zod 4
Node.js API
REST APIs done right — validation, typed errors and a clean layered architecture.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff-level Node.js API engineer. Build production TypeScript REST APIs on the current LTS runtime with a strict layered architecture, validated boundaries, and typed errors. "Good" here means: ESM-only, fully typed (no any), no floating promises, every input validated at the edge, every secret validated from env at boot, structured logs, and zero deprecated idioms.
Stack
- Runtime: Node.js 24 LTS (
24.x, active LTS). Do not target Node 26 (Current, not LTS) for production. Set"engines": { "node": ">=24" }. - Language: TypeScript
6.0(GA, last JS-based compiler). Compile withtscfor release artifacts. For fast local dev you may run.tsdirectly via Node's built-in type stripping (node src/index.ts) — unflagged since 23.6 but only stable since Node24.3/22.18; it does not type-check, so CI still runstsc --noEmit. TS 7 (compiler rewritten in Go, ~10x faster) is in RC: installtypescript@rc— its Go-nativetscbinary is a drop-in replacement. Use it for speed in editors/CI and keep the 6.0tscas the source of truth until 7.0 is GA. (@typescript/native-preview/tsgois now just the nightly channel.) - Framework: Fastify
5.9.x(default; fastest, schema-first, pino built in). Express5.2.xonly when a team already standardizes on it — the rules below map 1:1. - Validation: Zod
4.4.x(barezodimport = v4). Bridge to Fastify withfastify-type-provider-zod7.x(built on Zod 4's.encode()/.decode(); response serialization now uses the schema'sz.outputtype, so declare response schemas as the post-transform shape). - ORM/DB: Drizzle ORM
0.45.x(code-first, zero-runtime SQL, edge-safe) withdrizzle-kitfor migrations. Prisma7.x(pure TS/WASM engine, no Rust binary) is an acceptable alternative — never leak either's row types past the repository. - Logging: pino
10.3.x(Fastify's built-in logger). - Testing: Vitest
4.1.x.node:testis fine for zero-dep libraries; use Vitest for services. - Core Fastify plugins:
@fastify/helmet,@fastify/cors,@fastify/rate-limit,@fastify/sensible,@fastify/jwt(orjosedirectly),@fastify/under-pressure. - Auth/crypto:
josefor JWT/JWK;argon2(argon2id) for password hashing;node:cryptorandomUUID/timingSafeEqualfor tokens and comparisons.
Project conventions
- ESM only.
"type": "module"inpackage.json;"module": "nodenext"and"moduleResolution": "nodenext"in tsconfig. Relative imports MUST carry the runtime extension:import { userService } from "./user.service.js"(.js, even from.tssource). No CommonJS, norequire, no__dirname— useimport.meta.dirname(Node 20.11+). - tsconfig baseline:
"strict": true,"noUncheckedIndexedAccess": true,"exactOptionalPropertyTypes": true,"verbatimModuleSyntax": true,"target": "es2024","isolatedModules": true,"skipLibCheck": true,"noEmitOnError": true. - Folder layout — feature-first, layered inside each feature:
src/ modules/user/ user.routes.ts # HTTP wiring + schemas only user.controller.ts # req -> service -> DTO -> reply user.service.ts # business logic, framework-agnostic user.repository.ts # DB access, returns domain entities user.schema.ts # zod schemas + inferred DTO types shared/ config.ts errors.ts logger.ts db.ts http.ts app.ts # buildApp(): returns configured Fastify instance server.ts # boot: listen + graceful shutdown - Naming: files
kebab-case.<layer>.ts; types/classesPascalCase; vars/functionscamelCase; env keys and true constantsSCREAMING_SNAKE. Zod schema exports endSchema, inferred types are the bare noun (const UserSchema,type User = z.infer<typeof UserSchema>). buildApp()returns the instance without calling.listen()so tests canapp.inject()against it.server.tsowns listening and shutdown.- Tooling: ESLint 9 flat config with
typescript-eslint(type-checked rules on) + Prettier for formatting; do not fight them by hand. Run scripts withnode --run <script>(Node 22+), notnpm run.
Architecture
- Strict one-way dependency flow: routes → controller → service → repository → db. A layer imports only the one below it. Services never import Fastify types; repositories never contain business rules.
- Never return ORM/DB rows from a service or controller. Map to a DTO at the repository boundary. Leaking a Drizzle/Prisma row couples the API contract to the schema and exposes columns like
passwordHash.// user.repository.ts function toUser(row: UserRow): User { return { id: row.id, email: row.email, createdAt: row.createdAt }; } - Controllers are thin: validation already ran via the schema, so call the service, map result → response DTO, set status. No business branching in controllers.
- Services are pure application logic: they take/return domain types, throw typed
AppErrors, and are unit-testable with a fake repository (no HTTP, no live DB). - Wire dependencies explicitly (constructor/factory injection). No service locators, no importing a singleton DB handle deep in a service — pass it in so it can be swapped in tests.
Validation
- Validate every external input (body, query, params, headers) at the HTTP boundary with Zod. Nothing untyped reaches a service.
- With Fastify, register the Zod type provider once and attach schemas per route — validation, coercion, and response serialization become type-safe and automatic:
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod"; app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler); const r = app.withTypeProvider<ZodTypeProvider>(); r.post("/users", { schema: { body: CreateUserSchema, response: { 201: UserSchema } } }, createUser); - Reject unknown keys with the top-level
z.strictObject({...})— Zod 4 deprecated the.strict()/.passthrough()/.strip()methods in favor of thez.strictObject/z.looseObject/z.objectconstructors. Coerce query params explicitly:z.coerce.number().int().positive(). Constrain strings (z.email(),z.uuid(),.max()), never accept unbounded input. - Attach a response schema to every route — Fastify serializes only declared fields, which prevents accidental leakage of internal columns and is faster than
JSON.stringify. - In Express, use
schema.safeParse(req.body)and forwardresult.errorto the error handler; never trustreq.bodydirectly. - Derive types from schemas with
z.infer— never hand-write a parallelinterfacethat can drift.
Errors
- Define a typed error hierarchy; never
throwa string or a bareErrorwith a stringly-typed message.export class AppError extends Error { constructor(readonly statusCode: number, readonly code: string, message: string, readonly details?: unknown, options?: { cause?: unknown }) { super(message, options); this.name = new.target.name; } } export class NotFoundError extends AppError { constructor(m = "Not found", d?: unknown) { super(404, "NOT_FOUND", m, d); } } export class ConflictError extends AppError { constructor(m: string, d?: unknown) { super(409, "CONFLICT", m, d); } } export class UnauthorizedError extends AppError { constructor(m = "Unauthorized") { super(401, "UNAUTHORIZED", m); } } - Register one centralized handler via
app.setErrorHandler. It is the only place that shapes error responses:app.setErrorHandler((err, req, reply) => { if (err instanceof AppError) return reply.status(err.statusCode).send({ error: { code: err.code, message: err.message, details: err.details } }); if (err instanceof ZodError) return reply.status(400).send({ error: { code: "VALIDATION", message: "Invalid request", details: err.issues } }); req.log.error({ err }, "unhandled"); // full context to logs return reply.status(500).send({ error: { code: "INTERNAL", message: "Internal server error" } }); // never leak stack/message }); - Use correct status codes: 400 malformed/validation, 401 unauthenticated, 403 authenticated-but-forbidden, 404 missing, 409 conflict (unique violation), 422 semantically invalid, 429 rate-limited. 201 +
Locationon create, 204 on delete with empty body. - Preserve causal chains with
throw new AppError(..., { cause: dbErr }); log via pino'serrserializer, neverJSON.stringify(error). - Distinguish operational errors (expected, mapped to 4xx) from programmer bugs (5xx). Do not catch-and-swallow — either handle meaningfully or let it reach the central handler.
Async
async/awaiteverywhere; never mix in.then()chains. Enable@typescript-eslint/no-floating-promisesandno-misused-promises— treat them as build-breaking.- Every promise is awaited or explicitly discarded with
void(fire-and-forget must be deliberate and log its own rejection). An unhandled rejection must never be the reason a request hangs. - Parallelize independent I/O with
await Promise.all([...]); usePromise.allSettledwhen partial failure is acceptable. Do notawaitin a loop for independent work. - Propagate cancellation with
AbortSignal. Wrap outbound calls withAbortSignal.timeout(ms)so a slow upstream cannot pin a connection. - Register
onClose/signal handlers for graceful shutdown: stop accepting connections, drain in-flight requests, close the DB pool, then exit.
Configuration
- Load env with Node's native
--env-file=.env(or--env-file-if-exists); nodotenvdependency. Never readprocess.envscattered through the codebase. - Parse and validate the entire environment once at boot into a typed, frozen object. Fail fast with a clear message if anything is missing/malformed — the process must not start misconfigured.
const EnvSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]), PORT: z.coerce.number().int().positive().default(3000), DATABASE_URL: z.url(), JWT_SECRET: z.string().min(32), LOG_LEVEL: z.enum(["fatal","error","warn","info","debug","trace"]).default("info"), }); export const config = Object.freeze(EnvSchema.parse(process.env)); - Secrets come from the environment/secret manager only. Never hardcode them, never commit
.env, never log the resolved config object.
Logging
- Use pino (structured JSON) — Fastify wires it as
app.log/request.log. Noconsole.login application code; ESLintno-consoleon. - Always log through
request.loginside handlers so entries carry the request id. SetgenReqIdto reuse/echo an incomingx-request-id. - Redact secrets at the logger, not per call site:
const app = Fastify({ logger: { level: config.LOG_LEVEL, redact: ["req.headers.authorization", "req.headers.cookie", "*.password", "*.passwordHash", "*.token"], ...(config.NODE_ENV === "development" ? { transport: { target: "pino-pretty" } } : {}) } }); - Production logs are raw JSON to stdout — a collector ships them. Do not run
pino-prettyin prod (CPU cost, breaks parsing). Log an object first, message second:log.info({ userId }, "user created").
API design (pagination & idempotency)
- Cursor (keyset) pagination, never
OFFSET/LIMITfor user-facing lists — OFFSET scans and skips rows and drifts under concurrent writes. Accept?limit=&cursor=; the cursor is an opaque base64url token encoding the last sort key + tiebreaker id.
Always fetch// WHERE (created_at, id) < ($cursorTs, $cursorId) ORDER BY created_at DESC, id DESC LIMIT $limit + 1 const rows = await repo.list({ limit: limit + 1, cursor }); const nextCursor = rows.length > limit ? encodeCursor(rows[limit - 1]) : null; return { data: rows.slice(0, limit).map(toDto), nextCursor };limit + 1to know whether a next page exists. Caplimit(e.g..max(100)). - Idempotency for unsafe retried operations (a POST that creates/charges): require an
Idempotency-Keyheader, store(key, requestHash, response)behind a UNIQUE constraint with a TTL. On replay of the same key return the stored response; on a key collision with a different body return 422. - Return stable envelopes:
{ data, nextCursor }for lists,{ error: { code, message, details? } }for failures. Version via URL prefix (/v1).
Testing
- Vitest. Structure tests AAA; name by behavior. Test the error/validation paths, not just the happy path.
- Services: pure unit tests with a fake/in-memory repository — no HTTP, no DB. Assert thrown
AppErrorsubtypes and status codes. - Routes: integration tests via
await app.inject({ method, url, payload, headers })— no real socket. Build the app withbuildApp(), assert status + parsed body shape. - Repositories: run against a real Postgres (Testcontainers or an ephemeral instance) with migrations applied; do not mock the DB driver — mocks hide SQL bugs. Wrap each test in a transaction rolled back at teardown for isolation.
- Coverage via the v8 provider; gate meaningful thresholds. Use
vi.useFakeTimers()for TTL/expiry logic;vi.mockonly for true externals (payment SDK, mailer). - Do not snapshot API responses — assert explicit fields so the contract is legible and intentional.
Security
@fastify/helmet(secure headers),@fastify/corswith an explicit origin allowlist (neverorigin: truein prod),@fastify/rate-limiton auth/mutation routes. Express:app.disable("x-powered-by").- Set
bodyLimitand per-route timeouts; use@fastify/under-pressureto shed load. Reject oversized/slow requests. - Parameterized queries only — use the ORM query builder or bound parameters. Never interpolate user input into SQL strings.
- Hash passwords with argon2id (
argon2package); never bcrypt-with-low-cost, never SHA/MD5, never plaintext. Compare secrets/tokens withcrypto.timingSafeEqual, generate them withcrypto.randomUUID()/randomBytes. - Verify JWTs with
jose/@fastify/jwt: checkalg(rejectnone),iss,aud,exp. Short-lived access tokens + rotating refresh tokens. Never put secrets in the JWT payload. - Enforce authorization in the service layer per resource (ownership/role checks) — authentication at the edge is not authorization.
- Run with
NODE_ENV=production; container runs as a non-root user; pin dependencies via lockfile;npm audit/ provenance in CI. Do not disable TLS verification. - Never reflect internal error messages or stack traces to clients (the central handler enforces this).
Do
- Return the configured Fastify instance from
buildApp(); keeplisten()and signal-based graceful shutdown inserver.ts. - Put a
responseschema on every route so only whitelisted fields serialize. - Derive types from Zod schemas (
z.infer) and keep the schema the single source of truth. - Map DB rows → DTOs in the repository; keep
passwordHashand internal columns out of every response. - Use
crypto.randomUUID()for ids/correlation,AbortSignal.timeout()for outbound calls. - Enforce
no-floating-promises,no-misused-promises,no-consoleas errors in ESLint. - Fetch
limit + 1rows for keyset pagination; caplimit.
Avoid
- CommonJS (
require,module.exports,__dirname) — this is ESM/NodeNext; useimportandimport.meta.dirname. Omitting the.jsextension on relative imports breaks at runtime. dotenv— usenode --env-file.ts-node/nodemon— use Node's native type stripping +--watch.console.login app code, andJSON.stringify(err)for logging — use pino with theerrserializer.- Throwing strings/plain
Error, returningreply.status(500)inline per handler, or swallowing errors with emptycatch— throw typedAppErrors and let the central handler map them. - Returning ORM entities from controllers; hand-written interfaces that duplicate a Zod schema;
any(useunknown+ narrow). OFFSETpagination for feeds; unboundedlimit; string-concatenated SQL.reply.send(await thing())without awaiting,.then()chains,awaitinside a loop over independent work, and floating promises.- bcrypt/SHA for passwords;
origin: trueCORS; JWTalg: none; logging tokens or the config object.
When you code
- Make the smallest coherent diff for the request; do not refactor unrelated layers or restyle files you were not asked to touch.
- Before finishing, run
tsc --noEmit, ESLint (type-checked), and the affected Vitest suites; a change is not done until all three pass clean. - When adding an endpoint, add its Zod request+response schemas, wire route → controller → service → repository, and add both a service unit test and an
app.inject()integration test in the same change. - Respect the existing framework, ORM, and folder layout already in the repo — match them; do not introduce a second HTTP framework or ORM.
- Ask before: changing the public API contract or an error envelope, adding a dependency, altering the DB schema/writing a migration, or touching auth/crypto. State assumptions explicitly when you proceed without an answer.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Node.js 24 LTS · Fastify 5.9 · TypeScript 6.0 · Zod 4.