Skip to content
CalliCoder

NPM: The Node.js Package Manager Guide

Node.js 14 min read

npm ci against npm install and why CI should never use the second, what the caret actually allows, the lockfile that is the real dependency list, and audit output that is mostly not actionable.

npm installs packages, and most of what goes wrong with it comes from two things: npm install is allowed to change your dependency versions and npm ci is not, and the version ranges in package.json describe what is permitted rather than what is installed. The lockfile is the real answer to “what is in this build”.

Written against npm 10 and Node 20 LTS.

Starting a project

npm init -y
{
  "name": "my-app",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js",
  "engines": { "node": ">=20" },
  "scripts": {
    "start": "node index.js",
    "test": "node --test"
  }
}

Two fields are worth setting deliberately at the start.

"type": "module" makes .js files ES modules, so import works and require does not. Without it they are CommonJS. Mixing the two in one project is possible and is the source of most “Cannot use import statement outside a module” errors; decide once.

"engines" documents the Node version. It is advisory by default: add engine-strict=true to .npmrc to make a mismatch an error rather than a warning.

Installing

npm install express              # adds to dependencies
npm install --save-dev vitest    # devDependencies
npm install -g some-cli          # global, rarely what you want
npm install                      # install everything in package.json
npm ci                           # install exactly what is in the lockfile

--save has been the default since npm 5; it does nothing and can be dropped.

Global installs are worth avoiding. npx some-cli runs a package without installing it, and a project-local dev dependency invoked through a script is reproducible where a global install is a property of one machine.

npm ci is not an alias for npm install

This is the distinction that matters most.

npm installnpm ci
Readspackage.jsonpackage-lock.json
May update the lockfileyesnever
Existing node_modulesreusesdeletes first
Missing or mismatched lockfilecreates or updates itfails
Speedslowerfaster

npm install treats the lockfile as a starting point. If package.json allows a newer version, it may install it and rewrite the lock — so a CI job running npm install can build against dependencies nobody tested, and the difference appears as a failure with no corresponding commit.

npm ci installs exactly the lockfile and fails if it disagrees with package.json. Use it everywhere except when deliberately changing dependencies.

The corollary: commit package-lock.json. It is the record of what was actually installed, and without it npm ci cannot run at all.

Semver ranges

{
  "dependencies": {
    "express": "^4.18.2",
    "lodash": "~4.17.21",
    "react": "18.2.0",
    "internal-lib": "*"
  }
}
RangeAllowsExample
^4.18.2minor and patch4.19.0 yes, 5.0.0 no
~4.17.21patch only4.17.22 yes, 4.18.0 no
4.18.2exactly thatnothing else
>=4.0.0 <5explicit range
*anythingavoid

The caret is npm’s default and the source of most surprise. It permits any minor release, which under semver should be backward-compatible and in practice sometimes is not.

There is one special case worth knowing: for a 0.x version, the caret behaves like the tilde — ^0.2.3 allows 0.2.4 but not 0.3.0, because pre-1.0 packages are assumed to break on minor releases.

The caret is not a risk in itself, because the lockfile pins the resolved version. It becomes a risk the moment something runs npm install in CI, which is why the two topics belong together.

Updating deliberately

npm outdated                     # what could be updated
npm update                       # within the existing ranges
npm install express@latest       # change the range itself
npm install [email protected]       # pin exactly

npm outdated prints three columns — current, wanted and latest. Wanted is the newest version your range allows; latest is the newest published. A gap between current and wanted means npm update will move it. A gap between wanted and latest means the range has to change first.

npm update respects the ranges and rewrites the lockfile. Review that diff — it is the actual change, and package.json may not move at all.

Scripts

{
  "scripts": {
    "build": "esbuild src/index.js --bundle --outfile=dist/bundle.js",
    "test": "node --test",
    "lint": "eslint .",
    "prepare": "husky install",
    "check": "npm run lint && npm test"
  }
}

Scripts run with node_modules/.bin on the PATH, so a locally installed tool is callable by name with no path and no global install. That is the mechanism that makes global installs unnecessary.

npm run <name> runs one; npm test and npm start are shorthands for their own names. npm run with no argument lists them all, which is the fastest way into an unfamiliar project.

pre and post prefixes still run automatically — pretest before test. prepare runs after npm install and before publishing, which makes it the hook for setting up git hooks.

Arguments need a separator: npm test -- --watch passes --watch to the script rather than to npm.

Audit, and what to do with it

npm audit
npm audit --omit=dev
npm audit fix
npm audit fix --force            # may install breaking major versions

npm audit reports known vulnerabilities in the dependency tree, and the output is famously noisy. Two filters make it useful.

Is it in production code? A vulnerability in a build tool that never runs in production is a different priority from one in the HTTP framework. --omit=dev answers this.

Is the vulnerable path reachable? A prototype-pollution issue in a function your code never calls is not exploitable through your application. npm cannot determine this and reports it anyway.

npm audit fix --force is willing to install a major version to resolve an advisory, which can break the build with no warning beyond the flag’s name. Read what it proposes before running it.

For a transitive dependency with no fixed release, overrides forces a version:

{
  "overrides": {
    "semver": "^7.5.4"
  }
}

That is a blunt instrument — it can break the package that depended on the older version — and it is the only option when the maintainer has not published a fix.

Workspaces

{
  "name": "monorepo",
  "private": true,
  "workspaces": ["packages/*"]
}
npm install                              # installs for every workspace
npm run build --workspace=packages/api
npm run test --workspaces                # all of them
npm install lodash -w packages/api

One node_modules at the root, one lockfile, and cross-package dependencies symlinked rather than published. "private": true on the root is required — it prevents publishing the container by accident.

Cleaning up

rm -rf node_modules package-lock.json && npm install    # the nuclear option
npm cache clean --force
npm ls express                                          # why is this installed?
npm ls --depth=0                                        # top level only

npm ls <package> prints the dependency path to a package, which is how to find out which dependency pulled in the thing npm audit is complaining about. It is the single most useful diagnostic command and the least known.

Deleting the lockfile should be a last resort. It discards the record of a working set of versions, and the reinstall resolves everything afresh — which is exactly the non-reproducibility the lockfile exists to prevent.

Related: building a REST API with Express and MongoDB.

Frequently asked questions

What is the difference between npm install and npm ci?

npm install reads package.json and may update the lockfile; npm ci installs exactly the lockfile and fails if it disagrees. CI should use npm ci.

Should I commit package-lock.json?

Yes, always. It is the record of what was installed, and npm ci cannot run without it.

What does the caret allow?

Minor and patch updates — ^4.18.2 permits 4.19.0 but not 5.0.0. For a 0.x version it behaves like a tilde, allowing patches only.

Why did my build break with no code change?

Something ran npm install rather than npm ci and picked up a newer version the range allowed. The lockfile is the fix.

What is the difference between wanted and latest in npm outdated?

Wanted is the newest version your range permits; latest is the newest published. Reaching latest requires changing the range.

Do I need —save?

No. It has been the default since npm 5.

Should I install CLI tools globally?

Rarely. npx runs a package without installing, and a dev dependency called from a script is reproducible where a global install is not.

Is npm audit fix —force safe?

No. It will install major versions to clear an advisory and can break the build. Read the proposed changes first.

How do I find which dependency pulled in a package?

npm ls <package> prints the path through the tree. It is the fastest way to act on an audit report.

When should I delete package-lock.json?

Almost never. It discards a known-good set of versions and forces a fresh resolution, which is the opposite of what the file is for.