4 min read
On this page

npm & Package Management

npm (Node Package Manager) is the default package manager for Node.js. It connects your project to the largest ecosystem of open-source libraries in the world. Understanding how packages, dependencies, and lock files work is essential for any JavaScript project.

npm init & package.json

Every Node.js project starts with a package.json file. It describes the project and lists its dependencies.

npm init

This walks you through creating a package.json. Use npm init -y to accept all defaults.

{
  "name": "my-project",
  "version": "1.0.0",
  "description": "A web application",
  "main": "index.js",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "test": "vitest",
    "lint": "eslint ."
  },
  "dependencies": {
    "svelte": "^5.0.0"
  },
  "devDependencies": {
    "vite": "^6.0.0",
    "vitest": "^2.0.0",
    "eslint": "^9.0.0"
  }
}

Dependencies vs devDependencies

  • dependencies: packages your app needs to run in production (frameworks, utility libraries)
  • devDependencies: packages needed only during development (bundlers, test frameworks, linters)
npm install svelte           # Adds to dependencies
npm install -D vitest        # Adds to devDependencies
npm install --save-dev eslint  # Same as -D

Does It Matter?

For applications, the distinction is mostly organizational. For published libraries, it matters: consumers of your library install your dependencies but not your devDependencies.

npm install, update & audit

Installing

npm install              # Install all dependencies from package.json
npm install express      # Install a specific package and save it
npm install express@4    # Install a specific major version
npm uninstall express    # Remove a package

Updating

npm outdated             # Show packages with newer versions available
npm update               # Update all packages within semver range
npm install express@latest  # Update to latest, ignoring semver range

Auditing for Vulnerabilities

npm audit                # Check for known vulnerabilities
npm audit fix            # Auto-fix where possible
npm audit fix --force    # Fix even if it requires major version bumps

Semantic Versioning

npm uses semver: MAJOR.MINOR.PATCH.

Version Meaning
1.0.0 Initial release
1.0.1 Patch: bug fix, no API changes
1.1.0 Minor: new feature, backward compatible
2.0.0 Major: breaking changes

Range Syntax in package.json

{
  "dependencies": {
    "exact": "1.2.3",      // Only this version
    "caret": "^1.2.3",     // >=1.2.3 <2.0.0 (default)
    "tilde": "~1.2.3",     // >=1.2.3 <1.3.0
    "range": ">=1.0.0 <3.0.0"
  }
}

The caret (^) is the default and is the most common. It allows minor and patch updates.

Lock Files

package-lock.json records the exact version of every installed package, including transitive dependencies.

Why It Matters

Without a lock file, npm install resolves the latest versions within the semver range. Two developers running npm install at different times could get different versions, leading to "works on my machine" bugs.

Rules

  • Commit package-lock.json: it ensures everyone gets the same versions
  • Never edit it manually: npm manages it
  • Use npm ci in CI/CD: installs exactly what the lock file specifies, faster than npm install
npm ci    # Clean install from lock file (CI/CD)
npm install  # Resolve and update lock file (development)

npx

npx runs packages without installing them globally. It is included with npm.

# Run a package without installing
npx create-svelte my-app
npx eslint --init
npx vitest run

# Run a specific version
npx vite@5 build

When to Use npx

  • One-time commands (project scaffolding, migrations)
  • Running a tool without adding it to the project
  • Trying a specific version of a tool

pnpm as a Faster Alternative

pnpm is a drop-in replacement for npm that uses a content-addressable store. It is faster and uses less disk space.

# Install pnpm
npm install -g pnpm

# Usage is nearly identical to npm
pnpm install
pnpm add svelte
pnpm add -D vitest
pnpm run dev

How pnpm Saves Space

npm copies packages into each project's node_modules. If 10 projects use lodash, you have 10 copies.

pnpm stores every package version once on disk and creates hard links in node_modules. Ten projects using lodash share one copy.

# npm: each project copies all packages
project-a/node_modules/lodash/  (1.2 MB)
project-b/node_modules/lodash/  (1.2 MB)
project-c/node_modules/lodash/  (1.2 MB)

# pnpm: one copy, hard-linked everywhere
~/.pnpm-store/lodash@4.17.21/  (1.2 MB, single copy)
project-a/node_modules/.pnpm/lodash@4.17.21/  (hard link)
project-b/node_modules/.pnpm/lodash@4.17.21/  (hard link)

Strict node_modules

pnpm creates a strict node_modules structure. Packages can only access their declared dependencies, not transitive ones. This catches undeclared dependency bugs that npm/Yarn miss.

Monorepos with Workspaces

Workspaces let you manage multiple packages in a single repository.

npm Workspaces

{
  "name": "my-monorepo",
  "workspaces": [
    "packages/*",
    "apps/*"
  ]
}
my-monorepo/
  packages/
    shared-utils/
      package.json
    ui-components/
      package.json
  apps/
    web/
      package.json
    api/
      package.json
  package.json

Running Commands Across Workspaces

# Run tests in all workspaces
npm run test --workspaces

# Run build only in the web app
npm run build --workspace=apps/web

# Add a dependency to a specific workspace
npm install zod --workspace=packages/shared-utils

pnpm Workspaces

pnpm uses a pnpm-workspace.yaml file.

packages:
  - 'packages/*'
  - 'apps/*'
pnpm --filter web run build
pnpm --filter shared-utils add zod
pnpm -r run test    # Run test in all packages

The node_modules Problem

A typical project can have thousands of packages in node_modules, creating deeply nested directories with hundreds of megabytes of files.

Why It Gets So Big

Each package declares its own dependencies, which have their own dependencies. A simple project might directly depend on 20 packages but transitively install 800.

How pnpm Helps

  • Content-addressable store: only one copy of each package version on disk
  • Hard links: no duplication across projects
  • Strict resolution: prevents phantom dependencies (using packages you did not explicitly install)

Other Approaches

  • Deno: no node_modules at all, imports from URLs
  • Bun: built-in package manager with symlink-based resolution
  • Yarn PnP: virtual file system, no node_modules directory

Common Pitfalls

  • Not committing the lock file: without package-lock.json, builds are not reproducible. Someone on your team will get different versions.
  • Using npm install in CI: use npm ci instead. It is faster, stricter, and installs exactly what the lock file specifies.
  • Installing everything as dependencies: test frameworks, linters, and build tools belong in devDependencies. It keeps production installs smaller for libraries.
  • Ignoring npm audit warnings: vulnerabilities in your dependency tree are real. Run npm audit regularly and fix critical issues.
  • Global installs: avoid npm install -g. Use npx for one-time commands and local installs for project tools.
  • Phantom dependencies: if you use a package that a transitive dependency installed (but you did not declare), it will break when that transitive dependency is removed. pnpm prevents this.

Key Takeaways

  • package.json lists your project's dependencies; package-lock.json pins their exact versions
  • Always commit the lock file and use npm ci in CI/CD for reproducible builds
  • Use devDependencies for tools needed only during development (bundlers, linters, test frameworks)
  • npx runs tools without installing them globally
  • pnpm solves the disk space and phantom dependency problems with a content-addressable store
  • Workspaces enable monorepo management with shared dependencies across packages
  • Run npm audit regularly to catch known vulnerabilities in your dependency tree