Node.js

NPM & Package Management

15 Questions

package.json contains metadata and configuration for your Node.js project, including name, version, entry point (main), and runnable scripts. The name and version fields are required for publishable packages, while engines specifies supported Node.js versions. The scripts field defines commands that run via npm run, including lifecycle hooks like prestart and postinstall.
{
  "name": "my-app",
  "version": "1.0.0",
  "description": "A sample Node.js app",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js",
    "test": "jest"
  },
  "keywords": ["node", "api"],
  "author": "John Doe",
  "license": "MIT",
  "engines": { "node": ">=18.0.0" },
  "repository": { "type": "git", "url": "https://github.com/user/repo" }
}

Why it matters: package.json is the contract for your project — it defines what runs, what depends on what, and what gets published; understanding every field prevents deployment issues and version mismatches.

Real applications: The engines field prevents deployment to incompatible Node.js versions in CI; files prevents accidentally publishing node_modules, test fixtures, or private config files to the npm registry.

Common mistakes: Omitting the engines field and deploying to a Node.js version incompatible with your dependencies; also publishing sensitive files by not specifying files or adding a .npmignore.

dependencies are packages required at runtime, while devDependencies are only needed during development and testing. Runtime dependencies include frameworks and libraries your application needs to function; dev dependencies include testing frameworks, linters, and build tools. Only dependencies are installed when a consumer installs your package, and in production deployments NODE_ENV=production skips devDependencies.
// Add a runtime dependency
npm install express

// Add a dev-only dependency
npm install --save-dev jest eslint

// package.json
{
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "eslint": "^8.50.0"
  }
}

Why it matters: Installing devDependencies in production inflates image size, increases attack surface, and slows deployment; properly separating them is a Docker and security best practice.

Real applications: Docker multi-stage builds use npm ci --production in the final stage to exclude Jest, TypeScript compiler, and ESLint from the production image, reducing image size by 40-60%.

Common mistakes: Putting build tools like TypeScript or Webpack in dependencies instead of devDependencies — they get installed in production unnecessarily, increasing attack surface and install time.

Semver uses the MAJOR.MINOR.PATCH format where MAJOR increments indicate breaking changes, MINOR adds backward-compatible features, and PATCH delivers bug fixes. The caret (^) is the default range specifier, allowing minor and patch updates but not major; the tilde (~) is more conservative, allowing only patch-level updates. Exact versions pin dependencies completely, preventing any automatic upgrades in npm install.
// MAJOR — breaking changes
// MINOR — new features, backward-compatible
// PATCH — bug fixes, backward-compatible

// Range specifiers in package.json
"express": "4.18.2"    // Exact version only
"express": "^4.18.2"   // >=4.18.2 <5.0.0  (caret — default)
"express": "~4.18.2"   // >=4.18.2 <4.19.0 (tilde — patch only)
"express": ">=4.0.0"   // Any version 4 or above
"express": "*"          // Any version

Why it matters: Understanding semver ranges prevents both accidental breaking changes (too permissive range) and missing security patches (too restrictive range); it's the foundation of reliable dependency management.

Real applications: A library specifying "react": "^17.0.0 || ^18.0.0" works with both React versions; a mission-critical app may pin exact versions and rely on Dependabot PRs for controlled upgrades.

Common mistakes: Using "*" or ">= 1.0.0" for dependencies in package.json — this allows any future major version with breaking changes to be installed, causing unpredictable build failures over time.

package-lock.json records the exact version of every installed dependency and its sub-dependencies, ensuring deterministic installs across all machines. Without it, different developers or CI systems may resolve the same semver range to different versions, causing "works on my machine" bugs. Always commit package-lock.json to version control and use npm ci in CI/CD for clean, reproducible installs that fail if lock file and package.json disagree.
// package.json — specifies a range
"lodash": "^4.17.0"

// package-lock.json — locks exact version + integrity hash
"lodash": {
  "version": "4.17.21",
  "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
  "integrity": "sha512-..."
}

Why it matters: The lock file's integrity hashes verify that downloaded packages have not been tampered with — this is a supply chain security mechanism that detects malicious package modifications.

Real applications: CI pipelines run npm ci which installs from the lock file and fails if package.json and package-lock.json are out of sync, ensuring every build uses exactly the same dependency graph.

Common mistakes: Committing node_modules to version control instead of the lock file, or gitignoring package-lock.json — without the lock file, two npm install runs on the same package.json may produce different results months apart.

npx executes npm packages without installing them globally and comes bundled with npm 5.2+. It checks node_modules/.bin first, then downloads the package temporarily if not found, making it ideal for one-off commands, project scaffolding, and running specific package versions. npx avoids global installation pollution and ensures you always use the version of a tool specified in the current project.
// Run a package without installing
npx create-react-app my-app
npx cowsay "Hello!"

// Run a specific version
npx node@18 --version

// Run a locally installed binary
npx jest --watch
// equivalent to: ./node_modules/.bin/jest --watch

// Compare with npm
npm install -g create-react-app  // Installs globally
create-react-app my-app          // Then run

Why it matters: Running globally installed CLIs leads to version drift across team machines; npx ensures everyone uses the project-local version of tools like jest, eslint, and tsc as defined in devDependencies.

Real applications: Running npx create-next-app@latest scaffolds a project with the current version without polluting the global npm store; CI pipelines use npx tsc --noEmit to type-check without a global TypeScript install.

Common mistakes: Installing CLIs globally with npm install -g when the same tool is a devDependency — the global version may differ from the project version; use npx or npm run scripts to invoke the locally installed version.

npm scripts are defined in package.json under scripts and run via npm run <script>. Special scripts like start and test can be run without the run keyword, and pre/post hooks run automatically before and after their corresponding script. npm scripts have access to node_modules/.bin in the PATH, so locally installed tools like jest and eslint can be invoked directly.
{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "build": "tsc",
    "test": "jest --coverage",
    "lint": "eslint src/",
    "prestart": "npm run build",
    "posttest": "npm run lint"
  }
}

// Run scripts
npm start              // Special — no 'run' needed
npm test               // Special — no 'run' needed
npm run dev            // Custom scripts need 'run'
npm run build

Why it matters: npm scripts standardize how a project is built, tested, and started across all environments — any developer or CI system runs the same commands without needing project-specific documentation.

Real applications: A build script compiles TypeScript, a lint script runs ESLint, and a precommit script (via husky) runs both before each git commit; all orchestrated through package.json without shell scripts.

Common mistakes: Using cross-platform shell syntax (bash) in npm scripts that breaks on Windows; use the cross-env package for environment variables and rimraf instead of rm -rf for cross-platform compatibility.

npm audit scans your dependency tree for known security vulnerabilities using the npm advisory database and suggests fixes. It reports severity levels (low, moderate, high, critical) with CVE details, affected packages, and upgrade paths. Run it regularly and integrate it into your CI/CD pipeline to block deployments with unresolved high or critical vulnerabilities.
# Check for vulnerabilities
npm audit

# Output shows:
# Severity: low, moderate, high, critical
# Package, dependency path, and advisory details

# Automatically fix compatible vulnerabilities
npm audit fix

# Force fix (may include breaking changes)
npm audit fix --force

# Get a JSON report
npm audit --json

# Only show high/critical
npm audit --audit-level=high

Why it matters: Vulnerable dependencies are one of OWASP Top 10's "A06: Vulnerable and Outdated Components"; automated npm audit in CI prevents deploying known vulnerabilities to production.

Real applications: GitHub Dependabot runs automated security PRs based on npm audit findings; CI pipelines use npm audit --audit-level=high to fail builds when high or critical vulnerabilities are found.

Common mistakes: Ignoring audit warnings for "transitive" (indirect) dependencies — these are just as exploitable; use npm audit fix or explicit overrides to resolve them, not .auditignore silencing.

Publishing an npm package involves creating a free npmjs.com account, preparing your package with a proper package.json, and running npm publish. Use the files field or .npmignore to control which files are included, and test locally with npm link and npm pack before publishing. The npm version command bumps version in package.json and auto-creates a git tag, maintaining a clean release history.
# 1. Login to npm
npm login

# 2. Initialize package
npm init
# Set name, version, description, main, etc.

# 3. Add a .npmignore or use "files" in package.json
{
  "files": ["dist/", "README.md"]
}

# 4. Publish
npm publish

# 5. Publish scoped package (e.g., @myorg/mypackage)
npm publish --access public

# 6. Update version and republish
npm version patch   # 1.0.0 → 1.0.1
npm version minor   # 1.0.0 → 1.1.0
npm version major   # 1.0.0 → 2.0.0
npm publish

Why it matters: Publishing a malformed or overly large package with included node_modules, tests, or secrets is a common first-timer mistake that leaks sensitive data to the public registry permanently.

Real applications: Open-source utilities, React component libraries, and shared internal tools are distributed via the npm registry; scoped packages (@org/name) allow private packages with access controls in npm Teams.

Common mistakes: Not running npm pack before publishing to inspect what files will be included; many developers accidentally publish node_modules, .env files, or private keys without checking the tarball contents first.

Workspaces allow managing multiple packages within a single repository (monorepo), sharing dependencies and enabling cross-references between packages. They hoist shared dependencies to the root node_modules, reducing duplication and disk usage; local packages are automatically symlinked so changes take effect immediately. Workspaces are configured in the root package.json with a workspaces glob pattern.
// Root package.json
{
  "name": "my-monorepo",
  "workspaces": ["packages/*"]
}

// Directory structure
my-monorepo/
├── package.json
├── packages/
│   ├── shared/
│   │   └── package.json   // { "name": "@my/shared" }
│   ├── api/
│   │   └── package.json   // depends on "@my/shared"
│   └── web/
│       └── package.json

// Install all workspace dependencies
npm install

// Run script in a specific workspace
npm run build -w packages/api

// Add a dependency to a workspace
npm install lodash -w packages/shared

Why it matters: Workspaces eliminate the need to publish packages to npm for local development across multiple packages; the automatic symlinking enables seamless cross-package imports in a monorepo without any publish/install cycle.

Real applications: Fullstack monorepos (frontend, backend, shared types) use workspaces so the shared types package can be imported directly in both apps without publishing; UI component libraries share utilities between packages.

Common mistakes: Forgetting to use -w packages/api to scope commands to a specific workspace and accidentally installing a dependency in the wrong package; always use the --workspace or -w flag for targeted operations.

peerDependencies specify packages that your library requires to be installed by the consuming project, rather than bundling them as their own dependency. They are used by plugins and libraries that integrate with a host package (React, Webpack, Express) to prevent duplicate instances in the dependency tree. npm 7+ auto-installs peer dependencies; npm 3-6 only issued warnings.
// A React component library's package.json
{
  "name": "my-react-components",
  "peerDependencies": {
    "react": "^17.0.0 || ^18.0.0",
    "react-dom": "^17.0.0 || ^18.0.0"
  },
  "devDependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  }
}

Why it matters: Without peerDependencies, a React component library bundling its own React copy creates two React instances in the app, breaking hooks and causing hard-to-diagnose "invalid hook call" errors.

Real applications: React component libraries, Babel plugins, ESLint plugins, and Webpack loaders all use peerDependencies to declare which version of their host framework they're compatible with.

Common mistakes: Forgetting to test against the full range of declared peer dependency versions — declaring "react": "^17 || ^18" while only testing with React 18 may introduce subtle compatibility bugs for React 17 consumers.

npm, yarn, and pnpm are all package managers for Node.js with different approaches to dependency management and performance. npm is the default manager shipped with Node.js; yarn was created by Facebook for faster installs and pioneered workspaces; pnpm uses a content-addressable global store for maximum disk efficiency. Each uses its own lock file (package-lock.json, yarn.lock, pnpm-lock.yaml), so teams must align on a single manager.
// npm — default, ships with Node.js
npm install
npm install express
npm ci  // Clean install from lock file

// yarn — faster installs, workspaces pioneer
yarn
yarn add express
yarn install --frozen-lockfile

// pnpm — disk-efficient, strict dependency resolution
pnpm install
pnpm add express
pnpm install --frozen-lockfile

// Lock files
// npm  → package-lock.json
// yarn → yarn.lock
// pnpm → pnpm-lock.yaml

Why it matters: Mixing package managers in a project (npm install then yarn add) produces conflicting lock files, leading to inconsistent installs; standardizing on one manager and committing its lock file is a team hygiene requirement.

Real applications: pnpm is popular in large monorepos (e.g., Vue 3, Vite) because hard-linking from a global store saves gigabytes of disk compared to npm's per-project copies; CI caches the global store for fast installs.

Common mistakes: Using npm install in a project that uses pnpm or yarn — this generates a package-lock.json alongside the existing lock file, causing confusion; always use the project's designated package manager.

Environment-specific configuration enables different settings for development, staging, and production without code changes. The dotenv package loads variables from .env files into process.env for local development, while production environments set variables through the hosting platform. Never commit .env files — commit only .env.example as a template documenting required variables without actual values.
// .env file (never committed)
NODE_ENV=development
PORT=3000
DB_URL=mongodb://localhost:27017/mydb
JWT_SECRET=dev-secret-key

// .env.example (committed to git)
NODE_ENV=
PORT=
DB_URL=
JWT_SECRET=

// config.js
require('dotenv').config();

const config = {
  port: process.env.PORT || 3000,
  db: process.env.DB_URL,
  jwt: process.env.JWT_SECRET,
  isProduction: process.env.NODE_ENV === 'production'
};

module.exports = config;

Why it matters: Hardcoding database URLs, API keys, and JWT secrets in source code is a critical security vulnerability (OWASP A02: Cryptographic Failures) — environment variables keep secrets out of the codebase and version history.

Real applications: Kubernetes deployments inject secrets as environment variables via envFrom referencing K8s Secrets; AWS Lambda uses environment variables configured in the console or via CloudFormation.

Common mistakes: Accidentally committing the actual .env file to a public repository — even after deletion, secrets remain in git history; always add .env (not .env.example) to .gitignore before creating it.

Package deprecation signals that a package is no longer maintained and is typically replaced by a newer alternative; npm outdated shows what updates are available. Use npm update to apply safe patch/minor updates within declared semver ranges, or npm install express@latest for specific packages. Major version updates require manual intervention as they typically contain breaking changes.
# Check for outdated packages
npm outdated

# Output:
# Package    Current  Wanted  Latest  Location
# express    4.17.1   4.18.2  5.0.0   my-app
# lodash     4.17.20  4.17.21 4.17.21 my-app

# Apply safe updates (within semver range)
npm update

# Update a specific package to latest
npm install express@latest

# Check for deprecated packages
npm ls 2>&1 | grep -i deprecated

# Deprecate your own package
npm deprecate my-package@1.0.0 "Use my-package@2.0.0 instead"

# Interactive update tool
npx npm-check-updates -i

Why it matters: Outdated dependencies accumulate security vulnerabilities and become progressively harder to upgrade; a regular update cadence (monthly) prevents dependency debt from becoming an unmanageable migration burden.

Real applications: Dependabot or Renovate Bot automatically opens PRs for patch/minor updates; teams review and merge weekly, then handle major version upgrades quarterly with dedicated testing.

Common mistakes: Updating all dependencies at once with npx npm-check-updates -u && npm install without testing — bundling all updates makes it impossible to isolate which change caused a test failure.

npm link creates a symbolic link between a local package in development and a project that consumes it, enabling real-time testing without publishing. It works in two steps: run npm link in the package directory to register it globally, then run npm link package-name in the consuming project to link it. The file: protocol in package.json ("my-utils": "file:../my-utils") is a simpler alternative for monorepo setups.
# Step 1: In the library directory
cd ~/projects/my-utils
npm link
# Creates a global symlink: global/node_modules/my-utils → ~/projects/my-utils

# Step 2: In the consuming project
cd ~/projects/my-app
npm link my-utils
# Creates: my-app/node_modules/my-utils → global/node_modules/my-utils → ~/projects/my-utils

# Now changes in my-utils are immediately reflected in my-app

# To unlink when done
cd ~/projects/my-app
npm unlink my-utils
npm install  # Restore the published version

# Alternative: use file protocol in package.json
{
  "dependencies": {
    "my-utils": "file:../my-utils"
  }
}

Why it matters: npm link enables rapid iteration on shared libraries without a publish/install cycle; making a change in the library is instantly reflected in the consuming app without any intermediate steps.

Real applications: Developing an internal UI component library while simultaneously using it in the main app; npm link lets you see visual changes immediately without running a local registry or publishing a prerelease version.

Common mistakes: Forgetting to unlink before deploying — a symlinked node_modules entry pointing to a local path will not exist in the production environment, causing immediate startup failure.

npm overrides (npm 8.3+) allow forcing a specific version of a transitive dependency, useful for patching security vulnerabilities or resolving peer dependency conflicts. They replace the dependency version that a package would normally install with the version you specify in the overrides field of package.json. Use npm ls <package> to visualize the full dependency tree and identify exactly where a conflicting version originates.
// package.json — force a specific version of a transitive dependency
{
  "overrides": {
    // Force all instances of lodash to 4.17.21
    "lodash": "4.17.21",
    
    // Override only within a specific package
    "express": {
      "qs": "6.11.0"
    },
    
    // Use the same version as your direct dependency
    "react": "$react"
  }
}

// Check for duplicate/conflicting packages
npm ls lodash
# Shows all versions of lodash in the dependency tree

// Deduplicate packages
npm dedupe

// Clean install after overrides
rm -rf node_modules package-lock.json
npm install

Why it matters: When npm audit reports a high vulnerability in a transitive dependency that the direct dependency hasn't patched yet, overrides let you apply the fix immediately without waiting for the upstream maintainer.

Real applications: When a critical CVE is found in lodash@4.17.20 used by some-package@2.x, you add "overrides": { "lodash": "4.17.21" } to force the patched version across the entire dependency tree.

Common mistakes: Using overrides aggressively or with incompatible versions — forcing a major version upgrade on a transitive dependency can break the package that depends on it in unexpected ways; always run your full test suite after adding an override.