Skip to content
JavaScript js tooling 4 min read

npm & Package Management

Virtually every modern JavaScript project depends on third-party code, and npm (Node Package Manager) is the tool that fetches, installs, and tracks those dependencies. It ships with Node.js, reads a single package.json manifest to describe your project, and resolves a tree of packages from the public npm registry. Understanding how npm installs versions and locks them down is the difference between a build that works on every machine and one that mysteriously breaks in CI.

Initializing a project

Every npm project starts with a package.json file. Generate one with npm init, which prompts you for metadata, or skip the questions with the -y flag to accept defaults.

npm init -y

This creates a minimal manifest. The most important fields are name, version, scripts, and the two dependency maps:

{
  "name": "my-app",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node index.js",
    "test": "vitest"
  },
  "dependencies": {
    "express": "^4.19.2"
  },
  "devDependencies": {
    "vitest": "^1.6.0"
  }
}

Installing packages

Use npm install (or its alias npm i) to add packages. Where a package lands depends on the flag you pass.

CommandEffect
npm install expressAdds to dependencies (needed at runtime)
npm install -D vitestAdds to devDependencies (tooling only)
npm install -g serveInstalls globally, available as a CLI everywhere
npm installInstalls everything listed in package.json
npm uninstall expressRemoves a package and updates the manifest

The distinction between dependencies and devDependencies matters in production: a npm install --omit=dev skips dev tooling, producing leaner deployments and smaller container images.

Never commit the node_modules/ folder. It is large, platform-specific, and fully reproducible from package.json plus the lockfile. Add it to .gitignore.

Semantic versioning and ranges

npm packages follow semver — a MAJOR.MINOR.PATCH scheme. A bump in MAJOR signals breaking changes, MINOR adds backward-compatible features, and PATCH ships bug fixes. The prefix in front of a version controls which future versions npm is allowed to install.

RangeMeaningMatches
^4.19.2Compatible: allow minor/patch updates4.x.x, not 5.0.0
~4.19.2Allow patch updates only4.19.x, not 4.20.0
4.19.2Exact pinonly 4.19.2
>=4.0.0 <5.0.0Explicit rangeany matching version
*Any versionlatest (avoid)

The caret (^) is the default npm adds when you install, and it is the right choice for most libraries that respect semver. Run npm outdated to see which packages have newer versions, and npm update to move within the allowed ranges.

The lockfile

package.json records ranges, but a range like ^4.19.2 could resolve to 4.19.2 today and 4.21.0 next week. The package-lock.json file pins the exact version of every package in the resolved tree, including transitive dependencies, along with integrity hashes. This guarantees that every developer and every CI run installs an identical tree.

In automated environments, prefer npm ci over npm install. It installs strictly from the lockfile, errors if package.json and the lockfile disagree, and wipes node_modules first for a clean, deterministic build.

npm ci

Always commit package-lock.json. Treating it as disposable defeats reproducible installs and is a common source of “works on my machine” bugs.

Scripts

The scripts field maps names to shell commands, runnable with npm run <name>. The start and test scripts have shortcuts (npm start, npm test). Scripts run with node_modules/.bin on the PATH, so locally installed CLIs work without a global install.

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint src",
    "test": "vitest run"
  }
}

You can chain scripts and pass arguments after --:

npm run lint -- --fix

Running binaries with npx

npx executes a package’s binary without installing it permanently. It checks node_modules/.bin first, then downloads to a temporary cache if needed. This is ideal for one-off scaffolding tools.

npx create-vite@latest my-app
npx eslint --init

Output:

Need to install the following packages:
  create-vite@latest
Ok to proceed? (y)

Because npx always fetches the requested version, pin it with @latest or a specific version to avoid surprises from a stale cache.

Alternative package managers

npm is not the only option. Both alternatives read the same package.json and registry but differ in speed and disk usage.

ToolStrengths
npmBundled with Node, ubiquitous, no extra install
pnpmContent-addressable store with hard links — fast and disk-efficient; strict by default
YarnWorkspaces, plug’n’play mode, mature monorepo support

Each uses its own lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock), so a project should commit to exactly one. Switching tools means deleting the others’ lockfiles to avoid drift.

Best Practices

  • Commit both package.json and the lockfile; never commit node_modules/.
  • Use npm ci in CI/CD for fast, deterministic, lockfile-driven installs.
  • Put runtime needs in dependencies and tooling in devDependencies.
  • Keep the default caret ranges for libraries, but pin exact versions for fragile dependencies.
  • Audit regularly with npm audit and patch known vulnerabilities promptly.
  • Prefer npx over global installs for occasional CLI tools to avoid version conflicts.
  • Standardize on a single package manager per repository to keep one source of truth.
Last updated June 1, 2026
Was this helpful?