Skip to content

Managing Dependencies

npm separates concerns by dependency type. Rather than dumping every package into a single bucket, package.json distinguishes between packages your application needs at runtime, tools you only need during development, and packages that the consumer of your library must supply themselves. Getting these categories right keeps production installs lean and avoids dependency conflicts downstream.

dependencies lists packages that must be present for your application to run. When someone installs your package, npm installs these alongside it.

Terminal window
npm install express

This adds express to the "dependencies" section of package.json:

{
"dependencies": {
"express": "^4.18.2"
}
}

devDependencies — development-only tools

Section titled “devDependencies — development-only tools”

devDependencies contains packages used only while building, testing, or linting — they are never needed in production. npm skips them when NODE_ENV=production or when --omit=dev is passed.

Terminal window
npm install --save-dev eslint vitest typescript
{
"devDependencies": {
"eslint": "^8.57.0",
"typescript": "^5.4.5",
"vitest": "^1.6.0"
}
}

peerDependencies — consumer-provided packages

Section titled “peerDependencies — consumer-provided packages”

peerDependencies declares packages that the consuming project must install. This is the correct pattern for plugins, framework integrations, and shared libraries that should not be bundled separately in every package that uses them.

{
"name": "eslint-plugin-my-rules",
"version": "1.0.0",
"peerDependencies": {
"eslint": ">=8.0.0"
}
}

If a consumer installs eslint-plugin-my-rules without eslint present, npm prints a peer dependency warning. The plugin itself never includes its own copy of eslint — it uses the one in the consumer’s project.

package-lock.json records the exact resolved version and integrity hash of every package (and its transitive dependencies) that was installed. This guarantees that every developer and every CI run gets identical trees.

A minimal lockfile entry looks like:

{
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTg=="
}
}

Always commit package-lock.json to version control. Without it, npm install may resolve to newer patch or minor versions, silently breaking reproducible builds.

These two commands serve different purposes:

Terminal window
# npm install — resolves and potentially updates the lockfile
npm install
# npm ci — installs exactly what is in the lockfile; fails if package.json
# and package-lock.json are out of sync; always starts with a clean slate
npm ci
npm installnpm ci
LockfileMay updateMust match — fails if out of sync
node_modulesIncrementalDeleted and rebuilt from scratch
Use caseLocal developmentCI/CD pipelines, Docker builds
SpeedSlower (resolves)Faster (reads lockfile directly)
Which section of package.json should a test framework like Vitest be listed under?
What is the main purpose of peerDependencies?
What happens when you run `npm ci` and package.json is out of sync with package-lock.json?