Node.js Project Setup
When to Use
Use this skill when the user:
- Is initializing a new Node.js project from scratch and needs guidance on the full configuration stack
- Is deciding between ESM (ES Modules) and CommonJS and needs a concrete recommendation based on their context
- Needs to configure the
package.json exports field, main, module, or type fields correctly
- Wants to lock Node.js versions via
.nvmrc, .node-version, or the engines field in package.json
- Is setting up a Node.js library for npm publication and needs proper entry point configuration
- Is configuring a Node.js application for production deployment (Docker, serverless, PaaS) and needs engine locking, start scripts, and health check patterns
- Asks about
packageManager field, Corepack, or enforcing a specific package manager version on a team
Do NOT use this skill when the user:
- Needs TypeScript configuration,
tsconfig.json tuning, or declaration file generation -- use typescript-project-setup
- Asks about JavaScript language patterns like closures, prototypes, or async/await -- use
javascript-idioms
- Needs help with async concurrency patterns, event loop understanding, or Promise chains -- use
nodejs-async-patterns
- Asks about monorepo workspace tooling in depth (Turborepo, Nx, Lerna) -- use a dedicated monorepo skill
- Needs a full CI/CD pipeline design beyond basic npm scripts -- use a CI/CD configuration skill
- Is asking about Deno or Bun as a runtime -- those have meaningfully different module and config systems
Process
1. Gather Project Context Before Generating Any Configuration
Establish these facts before writing a single line of configuration. Wrong decisions here cascade into painful migrations later.
- Project type: Is this a library (published to npm), an application (deployed to a server or container), or a CLI tool? Libraries require dual-format output consideration and a precise
exports map. Applications only need to satisfy their own runtime.
- Deployment target: Cloud Run, AWS Lambda, Fly.io, a raw VPS, or a Docker container each have different Node.js version availability, cold-start constraints, and filesystem assumptions. Lambda's managed runtime pins you to specific LTS versions.
- Team size and experience: Solo projects can use bleeding-edge tooling. A team of 5+ needs enforced consistency via Corepack,
engines pinning, and pre-commit hooks.
- Minimum Node.js version requirement: If integrating with existing infrastructure, you may be constrained. If greenfield, target the current LTS (Node.js 20 as of 2024, Node.js 22 entering LTS in late 2024).
- Is there an existing codebase? If yes, determine its
type field setting and whether changing it (from CJS to ESM) is feasible without a flag day migration.
2. Make the ESM vs CommonJS Decision Explicitly
This is the most consequential choice and the one most often made by accident. Apply this decision framework:
Choose ESM ("type": "module" in package.json) when:
- Starting a new project with no legacy constraints
- All key dependencies support ESM (check with
node --input-type=module or inspect their exports map)
- The project is a library and you want to ship a single ESM-only package (acceptable since Node.js 12.17+)
- Using top-level
await, named exports, or dynamic import() heavily
- Targeting modern runtimes only (Node.js 18+)
Choose CommonJS (no "type" field, or "type": "commonjs") when:
- Integrating with a large body of existing CJS code that uses
require() extensively
- Key dependencies are CJS-only and do not have an ESM export condition (check their
package.json exports field for the "import" condition)
- Deploying to environments where the Node.js version is below 12.17
- The project is a library consumed by a mixed CJS/ESM ecosystem and you cannot provide a dual build
Choose a dual-format library build (CJS + ESM output) when:
- Publishing a library to npm that must support consumers on both module systems
- Using a build tool like
tsup, esbuild, or rollup to emit both .mjs/.cjs artifacts
- Setting up separate
exports conditions for "import" and "require"
- NOTE: Do NOT attempt to maintain dual format by hand -- always use a build tool
Key interop rules to communicate:
- ESM can import CJS via
import (Node.js will wrap the CJS module), but CJS cannot require() an ESM module -- this is a hard error
.mjs extension forces ESM; .cjs extension forces CJS, regardless of the "type" field
__dirname and __filename do not exist in ESM -- use import.meta.url with new URL('.', import.meta.url).pathname or the fileURLToPath helper from node:url
- Dynamic
import() works in both module systems and is the bridge from CJS to ESM modules
3. Configure package.json Fields in Correct Order
Build the package.json field by field with precise semantics. Do not omit fields that affect resolution or publication.
name: Use scoped names (@scope/package) for organizational packages or to avoid naming conflicts. All lowercase, hyphen-separated. Max 214 characters.
version: Use semver. Start at 0.1.0 for unreleased packages, 1.0.0 when you commit to a stable API. Use 0.x.y range while the API is volatile.
type: Set explicitly to "module" or "commonjs". Never rely on the default (CJS) as a silent assumption.
main: The CJS fallback entry point. Required for Node.js versions below 12. If ESM-only, still set it to point to your ESM entry for older tooling that ignores exports.
exports: The modern entry point map. Takes precedence over main in Node.js 12+. This is required for libraries. See the Output Format section for the exact structure.
files: Whitelist what ships in the npm tarball. Always include your dist/ or src/ directory. Always exclude node_modules, test files, and local config. The .npmignore approach is error-prone -- prefer files.
engines: Declare the minimum Node.js and npm/pnpm/yarn versions. This is advisory by default but can be made mandatory with engine-strict=true in .npmrc.
packageManager: Use this field with Corepack to enforce the exact package manager and version. Example: "packageManager": "pnpm@9.1.0". Run corepack enable once per machine to activate enforcement.
scripts: Keep script names consistent across projects. Use start for production server, dev for development with watch mode, build for compilation, test for test runner, lint for linter, format for formatter.
dependencies vs devDependencies: Production code dependencies go in dependencies. Build tools, test runners, linters, and type checkers go in devDependencies. For libraries, be conservative with dependencies -- every entry becomes a peer dependency concern for consumers.
4. Set Up Node.js Version Management
Version drift between local development and production is a common source of subtle bugs. Layer multiple enforcement mechanisms.
.nvmrc file: A single line containing the Node.js version string (e.g., 20.14.0 or lts/iron). Used by nvm, fnm, and mise. Place at the project root. Commit it to source control.
.node-version file: Same format as .nvmrc. Recognized by fnm, volta, and mise. Some teams maintain both; others pick one. Prefer .nvmrc for broadest tooling compatibility.
engines field in package.json: Declare "node": ">=20.0.0" (or a tighter range like ">=20.14.0 <21.0.0" for applications). This causes npm/pnpm/yarn to warn (or error with strict mode) when the installed version does not match.
.npmrc with engine-strict=true: Upgrades the engines field from a warning to a hard error during npm install. For team projects, add engine-strict=true to the project-level .npmrc and commit it.
- Volta (
volta field in package.json): Volta pins Node.js and package manager versions at the per-project level and auto-switches on directory entry without manual nvm use. Add: "volta": { "node": "20.14.0", "pnpm": "9.1.0" }.
- Docker base image pinning: In production containers, pin the exact image digest or tag:
FROM node:20.14.0-alpine3.20. Never use node:latest or node:lts in production Dockerfiles -- these are mutable tags.
- CI version matrix: In GitHub Actions or similar, test against the minimum declared engine version AND the current LTS using a matrix strategy. This catches regressions from version-specific behavior.
5. Configure the exports Map for Libraries
The exports field is the single most important and most misunderstood field for library authors. Get this right.
- Condition order matters: Node.js evaluates conditions in the order they appear in the object. Place
"types" first (for TypeScript consumers), then "import", then "require", then "default".
- Subpath exports: Use
"." for the main export and named subpaths like "./utils" for secondary entry points. This replaces deep require paths like require('mylib/dist/utils').
- Export blocking: Any path NOT listed in
exports is blocked from direct access in Node.js 12+ strict mode. Use this intentionally to prevent consumers from importing internal modules.
- Wildcard patterns: Use
"./features/*": "./dist/features/*.js" to expose an entire directory while still enforcing the .js extension.
- The
package.json self-referencing export: Always include "./package.json": "./package.json" so consumers can read your package metadata.
- Dual-package hazard: When shipping both CJS and ESM, there is a risk of the package being instantiated twice (once as CJS, once as ESM) in the same process, breaking singleton patterns. Mitigate with a wrapper-only CJS build that re-exports the ESM implementation, or use the
exports map carefully to prevent dual loading.
6. Configure Development Tooling and Scripts
Set up the minimum viable toolchain for a productive Node.js development experience.
- Watch mode (applications): Use
node --watch src/index.js (available since Node.js 18.11.0) for zero-dependency file watching. For more complex reload logic, nodemon remains the practical standard: nodemon --ext js,json,mjs src/index.js.
- Environment variables: Use
node --env-file=.env (available since Node.js 20.6.0) to load .env files natively without dotenv. For Node.js below 20.6, dotenv is the standard. Always add .env to .gitignore. Commit a .env.example with all keys but no values.
- Linting: ESLint with
@eslint/js and eslint-plugin-n (the Node.js plugin). Configure with the flat config format (eslint.config.js) for Node.js 18+ projects. Enable plugin:n/recommended to catch Node.js-specific mistakes like using unavailable APIs.
- Formatting: Prettier for zero-config opinionated formatting. The key Prettier setting for Node.js projects:
"trailingComma": "all" (aids git diffs), "singleQuote": true (common JS convention), "printWidth": 100.
- Pre-commit hooks:
simple-git-hooks (lightweight, 0 dependencies) or husky (more features). Run eslint and prettier --check on staged files via lint-staged. This prevents bad code from entering the repository.
- Testing: Use the built-in Node.js test runner (
node:test module, stable since Node.js 20) for new projects to eliminate dependencies. For more features (coverage, snapshot testing, mocking), vitest is the modern choice for ESM-native testing.
7. Configure Production Readiness
Production configuration is distinct from development setup and must be explicit.
NODE_ENV=production: Set this environment variable in production to enable production optimizations in Express, Next.js, and many other frameworks. Many packages short-circuit development checks, heap profiling, and detailed error messages when this is set.
- Process manager: For bare VPS or container deployments, use
pm2 in cluster mode for multi-core utilization: pm2 start src/index.js -i max --name app. For containerized deployments (Docker, Kubernetes), run Node.js directly as PID 1 with a SIGTERM handler -- do not wrap in a process manager inside containers.
- Graceful shutdown: Register
process.on('SIGTERM', ...) and process.on('SIGINT', ...) handlers. Close HTTP servers with server.close(), drain database connection pools, and flush any buffers before calling process.exit(0). Give the process 10-30 seconds maximum.
--max-old-space-size: Set heap limits explicitly in production. A Node.js process defaults to ~1.5GB on 64-bit systems. In containers, set this to 75-80% of the container's memory limit: node --max-old-space-size=3072 src/index.js for a 4GB container.
- Structured logging: Replace
console.log with a structured logger. pino is the standard for high-throughput production Node.js -- it serializes JSON at near-zero overhead. Never use winston for new projects (it is slower and more complex without benefit).
- Health check endpoint: Every deployed application must expose
GET /health returning 200 OK with response body {"status": "ok", "uptime": process.uptime()}. Orchestrators (Kubernetes, ECS) use this for liveness probes.
Output Format
When generating a Node.js project setup, produce ALL of the following artifacts in order.
Project Structure
my-app/
├── .nvmrc # Node.js version pin (e.g., "20.14.0")
├── .npmrc # npm/pnpm configuration (engine-strict=true)
├── .env.example # Environment variable template
├── .gitignore # Node.js-specific ignore patterns
├── eslint.config.js # ESLint flat config
├── package.json # Full configuration (see below)
├── src/
│ └── index.js # Application entry point
└── test/
└── index.test.js # Initial smoke test
package.json -- Application Template
{
"name": "my-app",
"version": "0.1.0",
"description": "Short description of the application",
"type": "module",
"engines": {
"node": ">=20.14.0",
"pnpm": ">=9.0.0"
},
"packageManager": "pnpm@9.1.0",
"volta": {
"node": "20.14.0",
"pnpm": "9.1.0"
},
"scripts": {
"start": "node src/index.js",
"dev": "node --watch --env-file=.env src/index.js",
"test": "node --test test/**/*.test.js",
"test:coverage": "node --test --experimental-test-coverage test/**/*.test.js",
"lint": "eslint src test",
"lint:fix": "eslint src test --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {},
"devDependencies": {
"@eslint/js": "^9.0.0",
"eslint": "^9.0.0",
"eslint-plugin-n": "^17.0.0",
"prettier": "^3.0.0",
"lint-staged": "^15.0.0",
"simple-git-hooks": "^2.0.0"
},
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*.{js,mjs,cjs}": ["eslint --fix", "prettier --write"],
"*.{json,md,yaml,yml}": ["prettier --write"]
}
}
package.json -- Library Template (Dual-Format Build)
{
"name": "@scope/my-lib",
"version": "1.0.0",
"description": "Short description of the library",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./utils": {
"import": {
"types": "./dist/utils.d.ts",
"default": "./dist/utils.js"
},
"require": {
"types": "./dist/utils.d.cts",
"default": "./dist/utils.cjs"
}
},
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"prepublishOnly": "pnpm build && pnpm test",
"test": "node --test test/**/*.test.js",
"lint": "eslint src test"
},
"devDependencies": {
"tsup": "^8.0.0"
},
"publishConfig": {
"access": "public"
}
}
.npmrc
engine-strict=true
save-exact=true
.nvmrc
20.14.0
.gitignore
# Dependencies
node_modules/
# Build output
dist/
build/
*.tsbuildinfo
# Environment variables
.env
.env.local
.env.*.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Coverage
coverage/
.nyc_output/
# Editor directories
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
# OS
.DS_Store
Thumbs.db
eslint.config.js (Flat Config, ESLint 9+)
import js from '@eslint/js'
import pluginN from 'eslint-plugin-n'
export default [
js.configs.recommended,
pluginN.configs['flat/recommended'],
{
rules: {
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'n/no-missing-import': 'error',
'n/no-unpublished-import': 'error',
'n/prefer-global/buffer': ['error', 'always'],
'n/prefer-global/process': ['error', 'always'],
},
languageOptions: {
ecmaVersion: 2024,
sourceType: 'module',
},
},
]
src/index.js -- Application Entry Point Shell
import { createServer } from 'node:http'
const PORT = process.env.PORT ?? 3000
const HOST = process.env.HOST ?? '0.0.0.0'
const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }))
return
}
res.writeHead(404)
res.end()
})
server.listen(PORT, HOST, () => {
console.log(`Server listening on http://${HOST}:${PORT}`)
})
// Graceful shutdown
const shutdown = (signal) => {
console.warn(`Received ${signal}, shutting down gracefully`)
server.close(() => {
console.warn('HTTP server closed')
process.exit(0)
})
// Force shutdown after 30 seconds
setTimeout(() => {
console.error('Forced shutdown after timeout')
process.exit(1)
}, 30_000).unref()
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
Verification Checklist
| Check |
Command |
Expected Result |
| Node.js version |
node --version |
Matches .nvmrc |
| Package manager |
pnpm --version |
Matches packageManager field |
| Install succeeds |
pnpm install |
No engine warnings or errors |
| Lint passes |
pnpm lint |
Zero violations |
| Format check |
pnpm format:check |
All files formatted |
| Tests pass |
pnpm test |
All tests green |
| Server starts |
pnpm start |
Prints listen message |
| Health endpoint |
curl localhost:3000/health |
{"status":"ok","uptime":...} |
Rules
Never omit the "type" field. Its absence defaults to "commonjs", which is an invisible assumption that breaks when someone later adds an .mjs file or a dependency that is ESM-only. Always declare "type": "module" or "type": "commonjs" explicitly so the intent is documented.
Never use bare node: builtins without the node: prefix in new ESM code. Always write import { readFile } from 'node:fs/promises' instead of import { readFile } from 'fs/promises'. The node: prefix prevents ambiguity with npm packages of the same name, is the documented modern practice, and is required for certain built-ins in some runtimes (Deno, Bun) for compatibility.
Never use "exports" subpath patterns that expose internal implementation paths. Once you list a path in exports, it becomes a public API surface. Unlisted paths are blocked. Use this as a feature -- intentionally exclude src/internal/** to prevent consumers from depending on internals.
Always run npm pack --dry-run (or pnpm pack --dry-run) before publishing a library to verify that the files field is correct and the tarball contains exactly what you intend. Shipping node_modules, test fixtures, or .env files in a published package is a security and size issue.
Never use * (wildcard) version ranges for dependencies in production applications. Use save-exact=true in .npmrc or pin versions manually. Floating ranges (^, ~) are acceptable in libraries (to allow consumers flexibility) but not in applications where reproducibility is critical.
Never mix CJS require() and ESM import syntax in the same file. In a "type": "module" project, require is not defined at the top level. In a CJS project, top-level await and static import are syntax errors. If you need to bridge module systems, use dynamic import() (works in CJS) or a conditional build.
Always set "private": true in package.json for applications that should never be published to npm. This prevents an accidental npm publish from leaking internal application code. Libraries should NOT set this field.
Always declare "sideEffects": false in library package.json if the library has no side effects (does not patch globals, register event listeners, or modify module state on import). This enables tree-shaking in bundlers like webpack, esbuild, and Rollup to eliminate dead code.
Never hard-code process.env.NODE_ENV checks without a fallback. Always use process.env.NODE_ENV ?? 'development' to avoid crashes in environments where this variable is not set. Many deployment platforms do not set NODE_ENV automatically.
Always handle uncaught exceptions and unhandled rejections in production applications. Register both process.on('uncaughtException', handler) and process.on('unhandledRejection', handler) to log the error with full stack trace before exiting. The default behavior (print and crash for exceptions, warn and continue for rejections in older Node.js) is not acceptable in production. In Node.js 15+, unhandled rejections crash the process by default -- which is the right behavior, but you should still log before exit.
Edge Cases
ESM-Only Dependency in a CJS Project
When a key dependency drops CJS support (this happened with chalk v5, node-fetch v3, nanoid v4, and many others), a CJS project cannot require() it. Options in order of preference:
- Pin to the last CJS-compatible version (e.g.,
chalk@4, node-fetch@2) if migration is not feasible right now.
- Migrate the entire project to ESM -- often the cleanest long-term solution.
- Use a dynamic
import() to load the ESM module from within CJS code. This forces the call site to be async and propagates await up the call chain.
- Use an alternative package that still ships CJS (
kleur instead of chalk, native fetch instead of node-fetch on Node.js 18+).
Do NOT advise using createRequire to load ESM -- it does not work and will produce a clear error.
Migrating an Existing CJS Codebase to ESM
This is a multi-step process that cannot be done atomically in large codebases. Guide the user through this sequence:
- Audit all
require() calls to identify dynamic requires (require(someVariable)) -- these have no ESM equivalent and must be replaced with dynamic import().
- Audit all uses of
__dirname and __filename -- replace with the fileURLToPath(new URL('.', import.meta.url)) pattern.
- Add file extensions to all relative imports. ESM requires explicit extensions (
./utils.js, not ./utils). This is the most tedious step in large codebases.
- Change
module.exports = ... to export default ... or named exports.
- Change all
require() calls to import statements.
- Set
"type": "module" in package.json.
- Run tests. Address any remaining interop issues.
Never attempt steps 4-7 before 1-3 are complete. Use a codemod tool like @es-migrations/named-exports or a custom AST transform with jscodeshift for large codebases -- manual edits across hundreds of files will introduce inconsistencies.
Lambda and Serverless Constraints
AWS Lambda's managed Node.js runtimes lag behind the current LTS. As of 2024, Lambda supports Node.js 18.x and 20.x as managed runtimes. Key constraints:
- Pin
"engines" to exactly the Lambda runtime version ("node": "=20.9.0" for Lambda's 20.x runtime).
- ESM is supported on Lambda with the
"type": "module" approach, but cold start times are measurably higher for ESM bundles due to module graph resolution. For latency-sensitive functions, benchmark both and consider bundling with esbuild into a single CJS file to eliminate this overhead.
- The
--env-file flag is not useful on Lambda -- use process.env directly, populated by Lambda environment variable configuration.
SIGTERM is sent to Lambda functions 300ms before the execution context freezes -- your shutdown handler has 300ms, not 30 seconds. Size your shutdown logic accordingly.
Monorepo with Mixed Module Systems
When a monorepo contains packages that are CJS and packages that are ESM, isolation is critical:
- Each package must have its own
package.json with an explicit "type" field. The root package.json's "type" field does NOT apply to subpackages.
- Use workspace-level ESLint configuration with per-package overrides for module system rules.
- In pnpm workspaces, use
pnpm --filter <package-name> <command> to run scripts in a specific package to avoid cross-package contamination.
- Cross-package imports within the monorepo should always go through the package's published
exports map, never via relative paths that bypass the exports field. This is enforced by setting "moduleResolution": "bundler" or "node16" in TypeScript, or via eslint-plugin-n rules.
Corepack and packageManager Field Enforcement
The packageManager field works only when Corepack is enabled. The workflow for new contributors:
- Document in
README.md that contributors must run corepack enable once after installing Node.js.
- If a contributor uses the wrong package manager (e.g., runs
npm install in a pnpm-managed workspace), Corepack will block the command with a clear error message.
- Corepack downloads the specified package manager version on demand -- contributors do not need to install pnpm separately.
- For CI, add
- run: corepack enable as the first setup step before any install commands.
- Pin the
packageManager field to a full semver string including patch version ("pnpm@9.1.0", not "pnpm@9"). Corepack uses this to download the exact binary.
Node.js Version Mismatch Between Team Members
When team members are on different Node.js versions and experiencing inconsistent behavior:
- Confirm
engine-strict=true is in the project .npmrc. If not, add it -- this turns engine mismatch warnings into install-blocking errors.
- Add a
.nvmrc check to the preinstall npm lifecycle script: "preinstall": "node -e \"const v=process.version; const r='>=20.14.0'; require('semver').satisfies(v,r)||process.exit(1)\"". This requires semver as a devDependency.
- In GitHub Actions, pin the Node.js version with
node-version-file: '.nvmrc' in the actions/setup-node step. This automatically reads the .nvmrc file rather than requiring manual updates to the workflow YAML.
- Document the version requirement in
README.md with exact instructions for both nvm and fnm users.
Example
Input: "I'm building a new REST API with Node.js that will run on Fly.io. The team is 3 developers. I want to use ESM, pnpm, and make sure everyone is on the same Node.js version. What should I set up?"
Analysis:
- Project type: Application (not a library -- no need for
exports map or dual format)
- Deployment target: Fly.io -- a container-based platform, so Dockerfile and graceful shutdown matter
- Team size: 3 -- Corepack enforcement and pre-commit hooks are worth the setup cost
- Module system: ESM explicitly requested
- Package manager: pnpm explicitly requested
- Node.js version: Use current LTS (20.14.0) since Fly.io runs whatever you put in the Dockerfile
Output:
.nvmrc
20.14.0
.npmrc
engine-strict=true
save-exact=true
package.json
{
"name": "my-api",
"version": "0.1.0",
"description": "REST API deployed to Fly.io",
"private": true,
"type": "module",
"engines": {
"node": ">=20.14.0",
"pnpm": ">=9.0.0"
},
"packageManager": "pnpm@9.1.0",
"volta": {
"node": "20.14.0",
"pnpm": "9.1.0"
},
"scripts": {
"start": "node --max-old-space-size=512 src/index.js",
"dev": "node --watch --env-file=.env src/index.js",
"test": "node --test test/**/*.test.js",
"test:coverage": "node --test --experimental-test-coverage test/**/*.test.js",
"lint": "eslint src test",
"lint:fix": "eslint src test --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"pino": "9.2.0"
},
"devDependencies": {
"@eslint/js": "9.5.0",
"eslint": "9.5.0",
"eslint-plugin-n": "17.9.0",
"lint-staged": "15.2.7",
"prettier": "3.3.2",
"simple-git-hooks": "2.11.1"
},
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*.{js,mjs}": ["eslint --fix", "prettier --write"],
"*.{json,md,yaml,yml}": ["prettier --write"]
}
}
Key decisions made:
"private": true -- this is an application, not an npm package
--max-old-space-size=512 -- Fly.io's free tier VMs have 256-512MB RAM; 512MB heap leaves headroom for OS overhead
pino as the logger -- production-grade, JSON output, compatible with Fly.io's log aggregation
save-exact=true -- application should have reproducible installs; no floating ranges
src/index.js
import { createServer } from 'node:http'
import { createLogger } from './logger.js'
const log = createLogger()
const PORT = process.env.PORT ?? 8080
const HOST = '0.0.0.0' // Required on Fly.io -- must bind to all interfaces
const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }))
return
}
log.info({ method: req.method, url: req.url }, 'Request received')
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
})
server.listen(PORT, HOST, () => {
log.info({ port: PORT }, 'Server started')
})
const shutdown = (signal) => {
log.warn({ signal }, 'Shutdown signal received')
server.close(() => {
log.warn('HTTP server closed, exiting')
process.exit(0)
})
setTimeout(() => {
log.error('Graceful shutdown timed out, forcing exit')
process.exit(1)
}, 30_000).unref()
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('unhandledRejection', (reason) => {
log.error({ reason }, 'Unhandled promise rejection')
process.exit(1)
})
process.on('uncaughtException', (err) => {
log.error({ err }, 'Uncaught exception')
process.exit(1)
})
src/logger.js
import pino from 'pino'
export const createLogger = () => pino({
level: process.env.LOG_LEVEL ?? 'info',
// In production, output newline-delimited JSON for log aggregation
// In development, use pino-pretty (install separately as devDependency)
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
})
eslint.config.js
import js from '@eslint/js'
import pluginN from 'eslint-plugin-n'
export default [
js.configs.recommended,
pluginN.configs['flat/recommended'],
{
rules: {
'no-console': 'error', // Use pino, not console
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'n/no-missing-import': 'error',
'n/no-process-env': 'warn', // Prefer centralized config module
'n/prefer-global/process': ['error', 'always'],
},
languageOptions: {
ecmaVersion: 2024,
sourceType: 'module',
},
settings: {
n: { version: '>=20.14.0' },
},
},
]
.env.example
NODE_ENV=development
PORT=8080
LOG_LEVEL=info
test/index.test.js
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
// Smoke test -- verifies the module graph loads without errors
describe('Application smoke test', () => {
it('creates a logger without throwing', async () => {
const { createLogger } = await import('../src/logger.js')
const log = createLogger()
assert.ok(log, 'Logger should be created')
assert.equal(typeof log.info, 'function', 'Logger should have info method')
})
})
README.md section -- Team Setup
## Development Setup
**Prerequisites:**
- Node.js 20.14.0 (use nvm: `nvm use`, or fnm: `fnm use`)
- Enable Corepack (once per machine): `corepack enable`
**First-time setup:**
\`\`\`bash
corepack enable # Activates pnpm via Corepack
nvm use # Switches to Node.js 20.14.0
pnpm install # Install dependencies (engine version enforced)
cp .env.example .env # Create local environment file
pnpm dev # Start development server with watch mode
\`\`\`
Verification Run
# Confirm toolchain
node --version # v20.14.0
pnpm --version # 9.1.0
# Install and verify engines
pnpm install # Should complete without engine warnings
# Verify quality tools
pnpm lint # 0 problems
pnpm format:check # All matched files use Prettier formatting
# Verify tests
pnpm test # 1 test passed
# Verify server
pnpm start &
curl http://localhost:8080/health
# {"status":"ok","uptime":0.123}
1---2name: nodejs-project-setup3description: Guides expert-level Node.js project initialization: ESM vs CJS module system decision, package.json configuration, Node.js version management, engine locking, and production deployment configuration. Use when the user asks about Node.js project setup, ESM vs CommonJS, package.json configuration, Node.js version management, engine field, exports field. Do NOT use when the user asks about TypeScript setup (use `typescript-project-setup`), JavaScript idioms (use `javascript-idioms`), Node.js async patterns (use `nodejs-async-patterns`).4license: Apache-2.05---6# Node.js Project Setup78## When to Use910**Use this skill when the user:**11- Is initializing a new Node.js project from scratch and needs guidance on the full configuration stack12- Is deciding between ESM (ES Modules) and CommonJS and needs a concrete recommendation based on their context13- Needs to configure the `package.json` `exports` field, `main`, `module`, or `type` fields correctly14- Wants to lock Node.js versions via `.nvmrc`, `.node-version`, or the `engines` field in `package.json`15- Is setting up a Node.js library for npm publication and needs proper entry point configuration16- Is configuring a Node.js application for production deployment (Docker, serverless, PaaS) and needs engine locking, start scripts, and health check patterns17- Asks about `packageManager` field, Corepack, or enforcing a specific package manager version on a team1819**Do NOT use this skill when the user:**20- Needs TypeScript configuration, `tsconfig.json` tuning, or declaration file generation -- use `typescript-project-setup`21- Asks about JavaScript language patterns like closures, prototypes, or async/await -- use `javascript-idioms`22- Needs help with async concurrency patterns, event loop understanding, or Promise chains -- use `nodejs-async-patterns`23- Asks about monorepo workspace tooling in depth (Turborepo, Nx, Lerna) -- use a dedicated monorepo skill24- Needs a full CI/CD pipeline design beyond basic npm scripts -- use a CI/CD configuration skill25- Is asking about Deno or Bun as a runtime -- those have meaningfully different module and config systems2627---2829## Process3031### 1. Gather Project Context Before Generating Any Configuration3233Establish these facts before writing a single line of configuration. Wrong decisions here cascade into painful migrations later.3435- **Project type:** Is this a library (published to npm), an application (deployed to a server or container), or a CLI tool? Libraries require dual-format output consideration and a precise `exports` map. Applications only need to satisfy their own runtime.36- **Deployment target:** Cloud Run, AWS Lambda, Fly.io, a raw VPS, or a Docker container each have different Node.js version availability, cold-start constraints, and filesystem assumptions. Lambda's managed runtime pins you to specific LTS versions.37- **Team size and experience:** Solo projects can use bleeding-edge tooling. A team of 5+ needs enforced consistency via Corepack, `engines` pinning, and pre-commit hooks.38- **Minimum Node.js version requirement:** If integrating with existing infrastructure, you may be constrained. If greenfield, target the current LTS (Node.js 20 as of 2024, Node.js 22 entering LTS in late 2024).39- **Is there an existing codebase?** If yes, determine its `type` field setting and whether changing it (from CJS to ESM) is feasible without a flag day migration.4041---4243### 2. Make the ESM vs CommonJS Decision Explicitly4445This is the most consequential choice and the one most often made by accident. Apply this decision framework:4647- **Choose ESM (`"type": "module"` in `package.json`) when:**48 - Starting a new project with no legacy constraints49 - All key dependencies support ESM (check with `node --input-type=module` or inspect their `exports` map)50 - The project is a library and you want to ship a single ESM-only package (acceptable since Node.js 12.17+)51 - Using top-level `await`, named exports, or dynamic `import()` heavily52 - Targeting modern runtimes only (Node.js 18+)5354- **Choose CommonJS (no `"type"` field, or `"type": "commonjs"`) when:**55 - Integrating with a large body of existing CJS code that uses `require()` extensively56 - Key dependencies are CJS-only and do not have an ESM export condition (check their `package.json` `exports` field for the `"import"` condition)57 - Deploying to environments where the Node.js version is below 12.1758 - The project is a library consumed by a mixed CJS/ESM ecosystem and you cannot provide a dual build5960- **Choose a dual-format library build (CJS + ESM output) when:**61 - Publishing a library to npm that must support consumers on both module systems62 - Using a build tool like `tsup`, `esbuild`, or `rollup` to emit both `.mjs`/`.cjs` artifacts63 - Setting up separate `exports` conditions for `"import"` and `"require"`64 - NOTE: Do NOT attempt to maintain dual format by hand -- always use a build tool6566- **Key interop rules to communicate:**67 - ESM can import CJS via `import` (Node.js will wrap the CJS module), but CJS cannot `require()` an ESM module -- this is a hard error68 - `.mjs` extension forces ESM; `.cjs` extension forces CJS, regardless of the `"type"` field69 - `__dirname` and `__filename` do not exist in ESM -- use `import.meta.url` with `new URL('.', import.meta.url).pathname` or the `fileURLToPath` helper from `node:url`70 - Dynamic `import()` works in both module systems and is the bridge from CJS to ESM modules7172---7374### 3. Configure `package.json` Fields in Correct Order7576Build the `package.json` field by field with precise semantics. Do not omit fields that affect resolution or publication.7778- **`name`:** Use scoped names (`@scope/package`) for organizational packages or to avoid naming conflicts. All lowercase, hyphen-separated. Max 214 characters.79- **`version`:** Use semver. Start at `0.1.0` for unreleased packages, `1.0.0` when you commit to a stable API. Use `0.x.y` range while the API is volatile.80- **`type`:** Set explicitly to `"module"` or `"commonjs"`. Never rely on the default (CJS) as a silent assumption.81- **`main`:** The CJS fallback entry point. Required for Node.js versions below 12. If ESM-only, still set it to point to your ESM entry for older tooling that ignores `exports`.82- **`exports`:** The modern entry point map. Takes precedence over `main` in Node.js 12+. This is required for libraries. See the Output Format section for the exact structure.83- **`files`:** Whitelist what ships in the npm tarball. Always include your `dist/` or `src/` directory. Always exclude `node_modules`, test files, and local config. The `.npmignore` approach is error-prone -- prefer `files`.84- **`engines`:** Declare the minimum Node.js and npm/pnpm/yarn versions. This is advisory by default but can be made mandatory with `engine-strict=true` in `.npmrc`.85- **`packageManager`:** Use this field with Corepack to enforce the exact package manager and version. Example: `"packageManager": "pnpm@9.1.0"`. Run `corepack enable` once per machine to activate enforcement.86- **`scripts`:** Keep script names consistent across projects. Use `start` for production server, `dev` for development with watch mode, `build` for compilation, `test` for test runner, `lint` for linter, `format` for formatter.87- **`dependencies` vs `devDependencies`:** Production code dependencies go in `dependencies`. Build tools, test runners, linters, and type checkers go in `devDependencies`. For libraries, be conservative with `dependencies` -- every entry becomes a peer dependency concern for consumers.8889---9091### 4. Set Up Node.js Version Management9293Version drift between local development and production is a common source of subtle bugs. Layer multiple enforcement mechanisms.9495- **`.nvmrc` file:** A single line containing the Node.js version string (e.g., `20.14.0` or `lts/iron`). Used by `nvm`, `fnm`, and `mise`. Place at the project root. Commit it to source control.96- **`.node-version` file:** Same format as `.nvmrc`. Recognized by `fnm`, `volta`, and `mise`. Some teams maintain both; others pick one. Prefer `.nvmrc` for broadest tooling compatibility.97- **`engines` field in `package.json`:** Declare `"node": ">=20.0.0"` (or a tighter range like `">=20.14.0 <21.0.0"` for applications). This causes npm/pnpm/yarn to warn (or error with strict mode) when the installed version does not match.98- **`.npmrc` with `engine-strict=true`:** Upgrades the `engines` field from a warning to a hard error during `npm install`. For team projects, add `engine-strict=true` to the project-level `.npmrc` and commit it.99- **Volta (`volta` field in `package.json`):** Volta pins Node.js and package manager versions at the per-project level and auto-switches on directory entry without manual `nvm use`. Add: `"volta": { "node": "20.14.0", "pnpm": "9.1.0" }`.100- **Docker base image pinning:** In production containers, pin the exact image digest or tag: `FROM node:20.14.0-alpine3.20`. Never use `node:latest` or `node:lts` in production Dockerfiles -- these are mutable tags.101- **CI version matrix:** In GitHub Actions or similar, test against the minimum declared engine version AND the current LTS using a matrix strategy. This catches regressions from version-specific behavior.102103---104105### 5. Configure the `exports` Map for Libraries106107The `exports` field is the single most important and most misunderstood field for library authors. Get this right.108109- **Condition order matters:** Node.js evaluates conditions in the order they appear in the object. Place `"types"` first (for TypeScript consumers), then `"import"`, then `"require"`, then `"default"`.110- **Subpath exports:** Use `"."` for the main export and named subpaths like `"./utils"` for secondary entry points. This replaces deep require paths like `require('mylib/dist/utils')`.111- **Export blocking:** Any path NOT listed in `exports` is blocked from direct access in Node.js 12+ strict mode. Use this intentionally to prevent consumers from importing internal modules.112- **Wildcard patterns:** Use `"./features/*": "./dist/features/*.js"` to expose an entire directory while still enforcing the `.js` extension.113- **The `package.json` self-referencing export:** Always include `"./package.json": "./package.json"` so consumers can read your package metadata.114- **Dual-package hazard:** When shipping both CJS and ESM, there is a risk of the package being instantiated twice (once as CJS, once as ESM) in the same process, breaking singleton patterns. Mitigate with a wrapper-only CJS build that re-exports the ESM implementation, or use the `exports` map carefully to prevent dual loading.115116---117118### 6. Configure Development Tooling and Scripts119120Set up the minimum viable toolchain for a productive Node.js development experience.121122- **Watch mode (applications):** Use `node --watch src/index.js` (available since Node.js 18.11.0) for zero-dependency file watching. For more complex reload logic, `nodemon` remains the practical standard: `nodemon --ext js,json,mjs src/index.js`.123- **Environment variables:** Use `node --env-file=.env` (available since Node.js 20.6.0) to load `.env` files natively without `dotenv`. For Node.js below 20.6, `dotenv` is the standard. Always add `.env` to `.gitignore`. Commit a `.env.example` with all keys but no values.124- **Linting:** ESLint with `@eslint/js` and `eslint-plugin-n` (the Node.js plugin). Configure with the flat config format (`eslint.config.js`) for Node.js 18+ projects. Enable `plugin:n/recommended` to catch Node.js-specific mistakes like using unavailable APIs.125- **Formatting:** Prettier for zero-config opinionated formatting. The key Prettier setting for Node.js projects: `"trailingComma": "all"` (aids git diffs), `"singleQuote": true` (common JS convention), `"printWidth": 100`.126- **Pre-commit hooks:** `simple-git-hooks` (lightweight, 0 dependencies) or `husky` (more features). Run `eslint` and `prettier --check` on staged files via `lint-staged`. This prevents bad code from entering the repository.127- **Testing:** Use the built-in Node.js test runner (`node:test` module, stable since Node.js 20) for new projects to eliminate dependencies. For more features (coverage, snapshot testing, mocking), `vitest` is the modern choice for ESM-native testing.128129---130131### 7. Configure Production Readiness132133Production configuration is distinct from development setup and must be explicit.134135- **`NODE_ENV=production`:** Set this environment variable in production to enable production optimizations in Express, Next.js, and many other frameworks. Many packages short-circuit development checks, heap profiling, and detailed error messages when this is set.136- **Process manager:** For bare VPS or container deployments, use `pm2` in cluster mode for multi-core utilization: `pm2 start src/index.js -i max --name app`. For containerized deployments (Docker, Kubernetes), run Node.js directly as PID 1 with a `SIGTERM` handler -- do not wrap in a process manager inside containers.137- **Graceful shutdown:** Register `process.on('SIGTERM', ...)` and `process.on('SIGINT', ...)` handlers. Close HTTP servers with `server.close()`, drain database connection pools, and flush any buffers before calling `process.exit(0)`. Give the process 10-30 seconds maximum.138- **`--max-old-space-size`:** Set heap limits explicitly in production. A Node.js process defaults to ~1.5GB on 64-bit systems. In containers, set this to 75-80% of the container's memory limit: `node --max-old-space-size=3072 src/index.js` for a 4GB container.139- **Structured logging:** Replace `console.log` with a structured logger. `pino` is the standard for high-throughput production Node.js -- it serializes JSON at near-zero overhead. Never use `winston` for new projects (it is slower and more complex without benefit).140- **Health check endpoint:** Every deployed application must expose `GET /health` returning `200 OK` with response body `{"status": "ok", "uptime": process.uptime()}`. Orchestrators (Kubernetes, ECS) use this for liveness probes.141142---143144## Output Format145146When generating a Node.js project setup, produce ALL of the following artifacts in order.147148### Project Structure149150```151my-app/152├── .nvmrc # Node.js version pin (e.g., "20.14.0")153├── .npmrc # npm/pnpm configuration (engine-strict=true)154├── .env.example # Environment variable template155├── .gitignore # Node.js-specific ignore patterns156├── eslint.config.js # ESLint flat config157├── package.json # Full configuration (see below)158├── src/159│ └── index.js # Application entry point160└── test/161 └── index.test.js # Initial smoke test162```163164### `package.json` -- Application Template165166```json167{168 "name": "my-app",169 "version": "0.1.0",170 "description": "Short description of the application",171 "type": "module",172 "engines": {173 "node": ">=20.14.0",174 "pnpm": ">=9.0.0"175 },176 "packageManager": "pnpm@9.1.0",177 "volta": {178 "node": "20.14.0",179 "pnpm": "9.1.0"180 },181 "scripts": {182 "start": "node src/index.js",183 "dev": "node --watch --env-file=.env src/index.js",184 "test": "node --test test/**/*.test.js",185 "test:coverage": "node --test --experimental-test-coverage test/**/*.test.js",186 "lint": "eslint src test",187 "lint:fix": "eslint src test --fix",188 "format": "prettier --write .",189 "format:check": "prettier --check ."190 },191 "dependencies": {},192 "devDependencies": {193 "@eslint/js": "^9.0.0",194 "eslint": "^9.0.0",195 "eslint-plugin-n": "^17.0.0",196 "prettier": "^3.0.0",197 "lint-staged": "^15.0.0",198 "simple-git-hooks": "^2.0.0"199 },200 "simple-git-hooks": {201 "pre-commit": "pnpm lint-staged"202 },203 "lint-staged": {204 "*.{js,mjs,cjs}": ["eslint --fix", "prettier --write"],205 "*.{json,md,yaml,yml}": ["prettier --write"]206 }207}208```209210### `package.json` -- Library Template (Dual-Format Build)211212```json213{214 "name": "@scope/my-lib",215 "version": "1.0.0",216 "description": "Short description of the library",217 "type": "module",218 "main": "./dist/index.cjs",219 "module": "./dist/index.js",220 "exports": {221 ".": {222 "import": {223 "types": "./dist/index.d.ts",224 "default": "./dist/index.js"225 },226 "require": {227 "types": "./dist/index.d.cts",228 "default": "./dist/index.cjs"229 }230 },231 "./utils": {232 "import": {233 "types": "./dist/utils.d.ts",234 "default": "./dist/utils.js"235 },236 "require": {237 "types": "./dist/utils.d.cts",238 "default": "./dist/utils.cjs"239 }240 },241 "./package.json": "./package.json"242 },243 "files": [244 "dist",245 "README.md",246 "LICENSE"247 ],248 "engines": {249 "node": ">=18.0.0"250 },251 "scripts": {252 "build": "tsup src/index.ts --format esm,cjs --dts --clean",253 "prepublishOnly": "pnpm build && pnpm test",254 "test": "node --test test/**/*.test.js",255 "lint": "eslint src test"256 },257 "devDependencies": {258 "tsup": "^8.0.0"259 },260 "publishConfig": {261 "access": "public"262 }263}264```265266### `.npmrc`267268```ini269engine-strict=true270save-exact=true271```272273### `.nvmrc`274275```27620.14.0277```278279### `.gitignore`280281```gitignore282# Dependencies283node_modules/284285# Build output286dist/287build/288*.tsbuildinfo289290# Environment variables291.env292.env.local293.env.*.local294295# Logs296logs/297*.log298npm-debug.log*299yarn-debug.log*300yarn-error.log*301pnpm-debug.log*302303# Runtime data304pids/305*.pid306*.seed307*.pid.lock308309# Coverage310coverage/311.nyc_output/312313# Editor directories314.vscode/315.idea/316*.suo317*.ntvs*318*.njsproj319*.sln320321# OS322.DS_Store323Thumbs.db324```325326### `eslint.config.js` (Flat Config, ESLint 9+)327328```js329import js from '@eslint/js'330import pluginN from 'eslint-plugin-n'331332export default [333 js.configs.recommended,334 pluginN.configs['flat/recommended'],335 {336 rules: {337 'no-console': ['warn', { allow: ['warn', 'error'] }],338 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],339 'n/no-missing-import': 'error',340 'n/no-unpublished-import': 'error',341 'n/prefer-global/buffer': ['error', 'always'],342 'n/prefer-global/process': ['error', 'always'],343 },344 languageOptions: {345 ecmaVersion: 2024,346 sourceType: 'module',347 },348 },349]350```351352### `src/index.js` -- Application Entry Point Shell353354```js355import { createServer } from 'node:http'356357const PORT = process.env.PORT ?? 3000358const HOST = process.env.HOST ?? '0.0.0.0'359360const server = createServer((req, res) => {361 if (req.method === 'GET' && req.url === '/health') {362 res.writeHead(200, { 'Content-Type': 'application/json' })363 res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }))364 return365 }366367 res.writeHead(404)368 res.end()369})370371server.listen(PORT, HOST, () => {372 console.log(`Server listening on http://${HOST}:${PORT}`)373})374375// Graceful shutdown376const shutdown = (signal) => {377 console.warn(`Received ${signal}, shutting down gracefully`)378 server.close(() => {379 console.warn('HTTP server closed')380 process.exit(0)381 })382383 // Force shutdown after 30 seconds384 setTimeout(() => {385 console.error('Forced shutdown after timeout')386 process.exit(1)387 }, 30_000).unref()388}389390process.on('SIGTERM', () => shutdown('SIGTERM'))391process.on('SIGINT', () => shutdown('SIGINT'))392```393394### Verification Checklist395396| Check | Command | Expected Result |397|---|---|---|398| Node.js version | `node --version` | Matches `.nvmrc` |399| Package manager | `pnpm --version` | Matches `packageManager` field |400| Install succeeds | `pnpm install` | No engine warnings or errors |401| Lint passes | `pnpm lint` | Zero violations |402| Format check | `pnpm format:check` | All files formatted |403| Tests pass | `pnpm test` | All tests green |404| Server starts | `pnpm start` | Prints listen message |405| Health endpoint | `curl localhost:3000/health` | `{"status":"ok","uptime":...}` |406407---408409## Rules4104111. **Never omit the `"type"` field.** Its absence defaults to `"commonjs"`, which is an invisible assumption that breaks when someone later adds an `.mjs` file or a dependency that is ESM-only. Always declare `"type": "module"` or `"type": "commonjs"` explicitly so the intent is documented.4124132. **Never use bare `node:` builtins without the `node:` prefix in new ESM code.** Always write `import { readFile } from 'node:fs/promises'` instead of `import { readFile } from 'fs/promises'`. The `node:` prefix prevents ambiguity with npm packages of the same name, is the documented modern practice, and is required for certain built-ins in some runtimes (Deno, Bun) for compatibility.4144153. **Never use `"exports"` subpath patterns that expose internal implementation paths.** Once you list a path in `exports`, it becomes a public API surface. Unlisted paths are blocked. Use this as a feature -- intentionally exclude `src/internal/**` to prevent consumers from depending on internals.4164174. **Always run `npm pack --dry-run` (or `pnpm pack --dry-run`) before publishing a library** to verify that the `files` field is correct and the tarball contains exactly what you intend. Shipping `node_modules`, test fixtures, or `.env` files in a published package is a security and size issue.4184195. **Never use `*` (wildcard) version ranges for dependencies in production applications.** Use `save-exact=true` in `.npmrc` or pin versions manually. Floating ranges (`^`, `~`) are acceptable in libraries (to allow consumers flexibility) but not in applications where reproducibility is critical.4204216. **Never mix CJS `require()` and ESM `import` syntax in the same file.** In a `"type": "module"` project, `require` is not defined at the top level. In a CJS project, top-level `await` and static `import` are syntax errors. If you need to bridge module systems, use dynamic `import()` (works in CJS) or a conditional build.4224237. **Always set `"private": true` in `package.json` for applications that should never be published to npm.** This prevents an accidental `npm publish` from leaking internal application code. Libraries should NOT set this field.4244258. **Always declare `"sideEffects": false` in library `package.json` if the library has no side effects** (does not patch globals, register event listeners, or modify module state on import). This enables tree-shaking in bundlers like webpack, esbuild, and Rollup to eliminate dead code.4264279. **Never hard-code `process.env.NODE_ENV` checks without a fallback.** Always use `process.env.NODE_ENV ?? 'development'` to avoid crashes in environments where this variable is not set. Many deployment platforms do not set `NODE_ENV` automatically.42842910. **Always handle uncaught exceptions and unhandled rejections in production applications.** Register both `process.on('uncaughtException', handler)` and `process.on('unhandledRejection', handler)` to log the error with full stack trace before exiting. The default behavior (print and crash for exceptions, warn and continue for rejections in older Node.js) is not acceptable in production. In Node.js 15+, unhandled rejections crash the process by default -- which is the right behavior, but you should still log before exit.430431---432433## Edge Cases434435### ESM-Only Dependency in a CJS Project436437When a key dependency drops CJS support (this happened with `chalk` v5, `node-fetch` v3, `nanoid` v4, and many others), a CJS project cannot `require()` it. Options in order of preference:4384391. Pin to the last CJS-compatible version (e.g., `chalk@4`, `node-fetch@2`) if migration is not feasible right now.4402. Migrate the entire project to ESM -- often the cleanest long-term solution.4413. Use a dynamic `import()` to load the ESM module from within CJS code. This forces the call site to be async and propagates `await` up the call chain.4424. Use an alternative package that still ships CJS (`kleur` instead of `chalk`, native `fetch` instead of `node-fetch` on Node.js 18+).443444Do NOT advise using `createRequire` to load ESM -- it does not work and will produce a clear error.445446---447448### Migrating an Existing CJS Codebase to ESM449450This is a multi-step process that cannot be done atomically in large codebases. Guide the user through this sequence:4514521. Audit all `require()` calls to identify dynamic requires (`require(someVariable)`) -- these have no ESM equivalent and must be replaced with dynamic `import()`.4532. Audit all uses of `__dirname` and `__filename` -- replace with the `fileURLToPath(new URL('.', import.meta.url))` pattern.4543. Add file extensions to all relative imports. ESM requires explicit extensions (`./utils.js`, not `./utils`). This is the most tedious step in large codebases.4554. Change `module.exports = ...` to `export default ...` or named exports.4565. Change all `require()` calls to `import` statements.4576. Set `"type": "module"` in `package.json`.4587. Run tests. Address any remaining interop issues.459460Never attempt steps 4-7 before 1-3 are complete. Use a codemod tool like `@es-migrations/named-exports` or a custom AST transform with `jscodeshift` for large codebases -- manual edits across hundreds of files will introduce inconsistencies.461462---463464### Lambda and Serverless Constraints465466AWS Lambda's managed Node.js runtimes lag behind the current LTS. As of 2024, Lambda supports Node.js 18.x and 20.x as managed runtimes. Key constraints:467468- Pin `"engines"` to exactly the Lambda runtime version (`"node": "=20.9.0"` for Lambda's 20.x runtime).469- ESM is supported on Lambda with the `"type": "module"` approach, but cold start times are measurably higher for ESM bundles due to module graph resolution. For latency-sensitive functions, benchmark both and consider bundling with `esbuild` into a single CJS file to eliminate this overhead.470- The `--env-file` flag is not useful on Lambda -- use `process.env` directly, populated by Lambda environment variable configuration.471- `SIGTERM` is sent to Lambda functions 300ms before the execution context freezes -- your shutdown handler has 300ms, not 30 seconds. Size your shutdown logic accordingly.472473---474475### Monorepo with Mixed Module Systems476477When a monorepo contains packages that are CJS and packages that are ESM, isolation is critical:478479- Each package must have its own `package.json` with an explicit `"type"` field. The root `package.json`'s `"type"` field does NOT apply to subpackages.480- Use workspace-level ESLint configuration with per-package overrides for module system rules.481- In pnpm workspaces, use `pnpm --filter <package-name> <command>` to run scripts in a specific package to avoid cross-package contamination.482- Cross-package imports within the monorepo should always go through the package's published `exports` map, never via relative paths that bypass the `exports` field. This is enforced by setting `"moduleResolution": "bundler"` or `"node16"` in TypeScript, or via `eslint-plugin-n` rules.483484---485486### Corepack and `packageManager` Field Enforcement487488The `packageManager` field works only when Corepack is enabled. The workflow for new contributors:489490- Document in `README.md` that contributors must run `corepack enable` once after installing Node.js.491- If a contributor uses the wrong package manager (e.g., runs `npm install` in a `pnpm`-managed workspace), Corepack will block the command with a clear error message.492- Corepack downloads the specified package manager version on demand -- contributors do not need to install pnpm separately.493- For CI, add `- run: corepack enable` as the first setup step before any install commands.494- Pin the `packageManager` field to a full semver string including patch version (`"pnpm@9.1.0"`, not `"pnpm@9"`). Corepack uses this to download the exact binary.495496---497498### Node.js Version Mismatch Between Team Members499500When team members are on different Node.js versions and experiencing inconsistent behavior:501502- Confirm `engine-strict=true` is in the project `.npmrc`. If not, add it -- this turns engine mismatch warnings into install-blocking errors.503- Add a `.nvmrc` check to the `preinstall` npm lifecycle script: `"preinstall": "node -e \"const v=process.version; const r='>=20.14.0'; require('semver').satisfies(v,r)||process.exit(1)\""`. This requires `semver` as a devDependency.504- In GitHub Actions, pin the Node.js version with `node-version-file: '.nvmrc'` in the `actions/setup-node` step. This automatically reads the `.nvmrc` file rather than requiring manual updates to the workflow YAML.505- Document the version requirement in `README.md` with exact instructions for both `nvm` and `fnm` users.506507---508509## Example510511**Input:** "I'm building a new REST API with Node.js that will run on Fly.io. The team is 3 developers. I want to use ESM, pnpm, and make sure everyone is on the same Node.js version. What should I set up?"512513---514515**Analysis:**516- Project type: Application (not a library -- no need for `exports` map or dual format)517- Deployment target: Fly.io -- a container-based platform, so Dockerfile and graceful shutdown matter518- Team size: 3 -- Corepack enforcement and pre-commit hooks are worth the setup cost519- Module system: ESM explicitly requested520- Package manager: pnpm explicitly requested521- Node.js version: Use current LTS (20.14.0) since Fly.io runs whatever you put in the Dockerfile522523---524525**Output:**526527### `.nvmrc`528529```53020.14.0531```532533### `.npmrc`534535```ini536engine-strict=true537save-exact=true538```539540### `package.json`541542```json543{544 "name": "my-api",545 "version": "0.1.0",546 "description": "REST API deployed to Fly.io",547 "private": true,548 "type": "module",549 "engines": {550 "node": ">=20.14.0",551 "pnpm": ">=9.0.0"552 },553 "packageManager": "pnpm@9.1.0",554 "volta": {555 "node": "20.14.0",556 "pnpm": "9.1.0"557 },558 "scripts": {559 "start": "node --max-old-space-size=512 src/index.js",560 "dev": "node --watch --env-file=.env src/index.js",561 "test": "node --test test/**/*.test.js",562 "test:coverage": "node --test --experimental-test-coverage test/**/*.test.js",563 "lint": "eslint src test",564 "lint:fix": "eslint src test --fix",565 "format": "prettier --write .",566 "format:check": "prettier --check ."567 },568 "dependencies": {569 "pino": "9.2.0"570 },571 "devDependencies": {572 "@eslint/js": "9.5.0",573 "eslint": "9.5.0",574 "eslint-plugin-n": "17.9.0",575 "lint-staged": "15.2.7",576 "prettier": "3.3.2",577 "simple-git-hooks": "2.11.1"578 },579 "simple-git-hooks": {580 "pre-commit": "pnpm lint-staged"581 },582 "lint-staged": {583 "*.{js,mjs}": ["eslint --fix", "prettier --write"],584 "*.{json,md,yaml,yml}": ["prettier --write"]585 }586}587```588589**Key decisions made:**590- `"private": true` -- this is an application, not an npm package591- `--max-old-space-size=512` -- Fly.io's free tier VMs have 256-512MB RAM; 512MB heap leaves headroom for OS overhead592- `pino` as the logger -- production-grade, JSON output, compatible with Fly.io's log aggregation593- `save-exact=true` -- application should have reproducible installs; no floating ranges594595### `src/index.js`596597```js598import { createServer } from 'node:http'599import { createLogger } from './logger.js'600601const log = createLogger()602const PORT = process.env.PORT ?? 8080603const HOST = '0.0.0.0' // Required on Fly.io -- must bind to all interfaces604605const server = createServer((req, res) => {606 if (req.method === 'GET' && req.url === '/health') {607 res.writeHead(200, { 'Content-Type': 'application/json' })608 res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }))609 return610 }611612 log.info({ method: req.method, url: req.url }, 'Request received')613 res.writeHead(404, { 'Content-Type': 'application/json' })614 res.end(JSON.stringify({ error: 'Not found' }))615})616617server.listen(PORT, HOST, () => {618 log.info({ port: PORT }, 'Server started')619})620621const shutdown = (signal) => {622 log.warn({ signal }, 'Shutdown signal received')623 server.close(() => {624 log.warn('HTTP server closed, exiting')625 process.exit(0)626 })627 setTimeout(() => {628 log.error('Graceful shutdown timed out, forcing exit')629 process.exit(1)630 }, 30_000).unref()631}632633process.on('SIGTERM', () => shutdown('SIGTERM'))634process.on('SIGINT', () => shutdown('SIGINT'))635636process.on('unhandledRejection', (reason) => {637 log.error({ reason }, 'Unhandled promise rejection')638 process.exit(1)639})640641process.on('uncaughtException', (err) => {642 log.error({ err }, 'Uncaught exception')643 process.exit(1)644})645```646647### `src/logger.js`648649```js650import pino from 'pino'651652export const createLogger = () => pino({653 level: process.env.LOG_LEVEL ?? 'info',654 // In production, output newline-delimited JSON for log aggregation655 // In development, use pino-pretty (install separately as devDependency)656 transport: process.env.NODE_ENV !== 'production'657 ? { target: 'pino-pretty', options: { colorize: true } }658 : undefined,659})660```661662### `eslint.config.js`663664```js665import js from '@eslint/js'666import pluginN from 'eslint-plugin-n'667668export default [669 js.configs.recommended,670 pluginN.configs['flat/recommended'],671 {672 rules: {673 'no-console': 'error', // Use pino, not console674 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],675 'n/no-missing-import': 'error',676 'n/no-process-env': 'warn', // Prefer centralized config module677 'n/prefer-global/process': ['error', 'always'],678 },679 languageOptions: {680 ecmaVersion: 2024,681 sourceType: 'module',682 },683 settings: {684 n: { version: '>=20.14.0' },685 },686 },687]688```689690### `.env.example`691692```693NODE_ENV=development694PORT=8080695LOG_LEVEL=info696```697698### `test/index.test.js`699700```js701import { describe, it } from 'node:test'702import assert from 'node:assert/strict'703704// Smoke test -- verifies the module graph loads without errors705describe('Application smoke test', () => {706 it('creates a logger without throwing', async () => {707 const { createLogger } = await import('../src/logger.js')708 const log = createLogger()709 assert.ok(log, 'Logger should be created')710 assert.equal(typeof log.info, 'function', 'Logger should have info method')711 })712})713```714715### `README.md` section -- Team Setup716717```markdown718## Development Setup719720**Prerequisites:**721- Node.js 20.14.0 (use nvm: `nvm use`, or fnm: `fnm use`)722- Enable Corepack (once per machine): `corepack enable`723724**First-time setup:**725\`\`\`bash726corepack enable # Activates pnpm via Corepack727nvm use # Switches to Node.js 20.14.0728pnpm install # Install dependencies (engine version enforced)729cp .env.example .env # Create local environment file730pnpm dev # Start development server with watch mode731\`\`\`732```733734### Verification Run735736```bash737# Confirm toolchain738node --version # v20.14.0739pnpm --version # 9.1.0740741# Install and verify engines742pnpm install # Should complete without engine warnings743744# Verify quality tools745pnpm lint # 0 problems746pnpm format:check # All matched files use Prettier formatting747748# Verify tests749pnpm test # 1 test passed750751# Verify server752pnpm start &753curl http://localhost:8080/health754# {"status":"ok","uptime":0.123}755```