Package Management
Node.js projects rely on package managers to resolve, download, verify, and organize third-party libraries within the node_modules directory.
package.json vs. package-lock.json
| File | Purpose | Key Contents | Version Control |
|---|---|---|---|
package.json | Project manifest describing metadata, commands, and dependency constraints. | Names, scripts, loose semver ranges (dependencies, devDependencies, peerDependencies). | Commit to Git. |
package-lock.json (yarn.lock / pnpm-lock.yaml) | State snapshot locking the exact dependency graph. | Exact versions installed, integrity hashes (SHA-512), resolved URLs, transitive dependencies. | Always commit to Git. |
dependenciesvs.devDependencies:dependencies: Packages required for the application to function in production (e.g.,express,pg,zod). Installed when runningnpm install --omit=dev.devDependencies: Tools used solely for local development, compilation, linting, or testing (e.g.,typescript,jest,eslint,nodemon).
- Deterministic Builds (
npm ci): In CI/CD pipelines and production deployments, runnpm ciinstead ofnpm install. It deletesnode_modules, strictly validates againstpackage-lock.json, bypasses version resolution, and throws an error if the lockfile andpackage.jsonare out of sync.
Semantic Versioning (SemVer)
SemVer follows the format MAJOR.MINOR.PATCH:
- MAJOR (
X.0.0): Incompatible API changes (breaking changes). - MINOR (
0.X.0): Backwards-compatible new features. - PATCH (
0.0.X): Backwards-compatible bug fixes.
Package managers use prefix operators to manage automatic update ranges during npm install or npm update:
JSON
{
"dependencies": {
"exact-pkg": "1.4.2", // Strict match: installs exactly 1.4.2
"patch-pkg": "~1.4.2", // Tilde (~): Accepts PATCH updates (>= 1.4.2 < 1.5.0)
"minor-pkg": "^1.4.2", // Caret (^): Accepts MINOR & PATCH updates (>= 1.4.2 < 2.0.0)
"pre-zero-pkg": "^0.2.3", // Exception: If MAJOR is 0, ^ locks MINOR (>= 0.2.3 < 0.3.0)
"any-pkg": "*" // Wildcard: Installs latest version (risks breaking changes)
}
}
Local vs. Global Dependencies
- Local Dependencies (
npm install <pkg>):- Stored in
./node_modulesat the project root. - Scoped directly to the project, preventing version clashes between different projects on the same machine.
- Stored in
- Global Dependencies (
npm install -g <pkg>):- Stored in the system’s global directory (e.g.,
/usr/local/lib/node_moduleson Linux/macOS or%AppData%\npmon Windows). - Exposes binaries globally across your shell (e.g.,
npm,pnpm,pm2). - Avoid installing project-specific CLI tools globally (e.g., use local
typescriptoreslintinstead of global installations to prevent environment mismatches across team members).
- Stored in the system’s global directory (e.g.,
Modern Alternatives: PNPM & Yarn
- NPM: Standard default, uses a flattened
node_moduleslayout. Can lead to “phantom dependencies” (importing packages your dependencies use that you didn’t explicitly define). - Yarn: Introduced lockfiles, parallel downloads, and offline caching. Modern Yarn (Berry) supports Plug’n’Play (
PnP), removingnode_modulesentirely in favor of an optimized lookup table. - PNPM: Uses a content-addressable store on disk. All files are saved once in a global store and hard-linked into projects.
- Saves gigabytes of disk space across multiple projects.
- Uses isolated (non-flat) symlinked
node_modulesto eliminate phantom dependencies.
Scripts & Workflows
The scripts object in package.json allows defining custom commands, pre/post hooks, and environment configurations.
JSON
{
"scripts": {
"prebuild": "npm run clean",
"build": "tsc",
"postbuild": "node scripts/copy-assets.js",
"start": "node dist/index.js",
"dev": "node --watch --env-file=.env src/index.js",
"test": "jest",
"lint": "eslint .",
"clean": "rm -rf dist"
}
}
Lifecycle & Pre/Post Hooks
- Any script prefixed with
preorpostexecutes automatically before or after the base command. Runningnpm run buildsequentially executes:npm run prebuildnpm run buildnpm run postbuild
- Default commands like
npm startandnpm testdo not require therunkeyword; custom scripts requirenpm run <name>.
Passing Arguments
To pass flags or options through an npm script to the underlying binary, separate them with a double hyphen (--):
Bash
# Passes --coverage down directly to Jest
npm test -- --coverage
# Passes a port flag to a local dev server
npm run dev -- --port=8080
NPX (Node Package Execute)
npx comes bundled with NPM and serves two primary purposes:
- Running locally installed binaries without global installation: Instead of referencing
./node_modules/.bin/eslint ., run:Bashnpx eslint . - One-off execution of uninstalled packages:
npxdownloads the package to a temporary cache, executes the binary, and removes it immediately:Bashnpx create-next-app@latest my-app
Managing Secrets
Backend applications require sensitive credentials (database passwords, API tokens, encryption keys) that must never be hardcoded into the source code.
The .env File Conventions
.env: Contains real local development credentials. Must be added to.gitignore..env.example: A committed template displaying all required environment keys with blank or dummy values:Ini, TOML# .env.example PORT= DATABASE_URL= JWT_SECRET= NODE_ENV=
Method 1: Using dotenv (Standard Cross-Version Approach)
Install the package:
Bash
npm install dotenv
Load it as early as possible in your application lifecycle:
JavaScript
// ESM syntax
import 'dotenv/config';
// Or programmatic CJS syntax
// require('dotenv').config({ path: './custom.env' });
const port = process.env.PORT || 3000;
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
throw new Error('FATAL: DATABASE_URL is not set in environment.');
}
Preloading without modifying source code (recommended for production builds):
Bash
node -r dotenv/config dist/server.js
Method 2: Native Node.js .env Loading
Node.js provides built-in .env file parsing via the --env-file CLI flag, eliminating the need for third-party packages like dotenv:
Bash
# Load default .env
node --env-file=.env server.js
# Load environment-specific file
node --env-file=.env.production server.js
Validating Environment Variables (Fail-Fast Pattern)
Relying on raw process.env leaves room for runtime bugs if a required string is missing or misspelled. Validating the environment at startup ensures the process fails fast before accepting traffic.
Example with Zod:
JavaScript
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters')
});
// Parse process.env; throws a detailed ZodError on failure
export const env = envSchema.parse(process.env);
