Concept
The beginner framing: every Node.js project has a package.json, but beyond "name", "version", and a dependencies list, most of its fields go unexamined until something breaks and forces a closer look. Understanding them ahead of time avoids that.
The precise mental model: package.json is both a manifest (metadata, entry points, dependency declarations) and a configuration surface (scripts, engine constraints, module resolution behavior), and several of its fields directly control how Node resolves and loads the package's code.
{
"name": "my-package",
"version": "1.4.2",
"type": "module",
"main": "./dist/index.js",
"exports": {
".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" },
"./utils": "./dist/utils.js"
},
"engines": { "node": ">=20.0.0" },
"bin": { "my-cli": "./bin/cli.js" },
"scripts": { "build": "tsc", "test": "node --test" },
"dependencies": { "express": "^4.18.0" },
"devDependencies": { "typescript": "^5.9.0" }
}"type", determines whether.jsfiles in this package are CommonJS or ESM by default (covered in depth in Modules & File System)."exports", the modern, more precise successor to"main": an explicit map controlling exactly which files consumers are allowed toimport/require, and which module system they get for each, this is what enables a single package to correctly serve bothrequire()andimportconsumers with different files."engines", declares the Node version range the package requires; npm warns (doesn't block by default) if the installed version doesn't satisfy it.
Semver ranges, actually decoded
{
"dependencies": {
"exact": "4.18.2",
"patch-only": "~4.18.2",
"minor-and-patch": "^4.18.2",
"anything-above": ">=4.18.2"
}
}Given a version MAJOR.MINOR.PATCH:
| Range | Allows | Meaning |
|---|---|---|
4.18.2 (exact) | Only 4.18.2 | No flexibility at all |
~4.18.2 | 4.18.x, x ≥ 2 | Patch updates only, bug fixes |
^4.18.2 | 4.x.x, where the result is ≥ 4.18.2 |
^ (caret) is npm's default when you npm install <package>, it assumes the package author follows semver honestly (breaking changes only in major version bumps), which is a convention, not something npm can enforce.
Lockfiles: reproducibility across machines and time
// package-lock.json (excerpt)
{
"packages": {
"node_modules/express": {
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
"integrity": "sha512-..."
}
}
}package.json's ^4.18.2 is a range, it doesn't pin an exact version. package-lock.json pins the exact resolved version (and a cryptographic integrity hash) for every package in the entire dependency tree, including transitive dependencies your package.json never mentions directly. Without a committed lockfile, two different machines running npm install against the same package.json at different times could genuinely resolve different actual versions, the lockfile is what guarantees everyone (and CI) gets the identical dependency tree.
npm ci vs. npm install: not interchangeable
npm install # reads package.json, resolves ranges, UPDATES the lockfile if needed
npm ci # reads package-lock.json ONLY, deletes node_modules first,
# FAILS OUTRIGHT if package.json and the lockfile disagreeThis is the distinction most easily missed: npm install is forgiving, if package.json and package-lock.json are slightly out of sync, it reconciles them (updating the lockfile) and proceeds. npm ci is strict and fast, it does a clean install using only what's already locked, and refuses to proceed at all if there's a mismatch, rather than silently resolving one. This is exactly why npm ci is the correct choice for CI pipelines and production builds: a mismatch there should be a loud, immediate failure, not something silently patched over mid-build.
Workspaces: monorepo basics
// root package.json
{
"workspaces": ["packages/*"]
}The "workspaces" field lets a single npm install at the repo root manage dependencies for multiple packages living in one repository, hoisting shared dependencies to a single top-level node_modules where possible and symlinking internal packages to each other, the standard mechanism behind most JavaScript monorepos.
Try It
Predict the outcome before checking the solution.
A repo's package.json has "express": "^4.18.0" in dependencies, but the committed package-lock.json still references express@4.17.9 from before a recent manual package.json edit that bumped the range.
npm ciDoes this succeed, silently resolve to a version satisfying ^4.18.0, or fail?
Solution
It fails outright, npm ci requires package.json and package-lock.json to already agree; it does not resolve or reconcile a mismatch the way npm install would. The error explicitly calls out the discrepancy between the declared range and what's locked. The fix is running npm install locally (which updates the lockfile to satisfy the new range), committing the updated package-lock.json, and only then will npm ci succeed again, this is a deliberate design choice, not a limitation, since it prevents a stale lockfile from silently drifting from what package.json actually declares.
Implement It Yourself
Build a minimal semver-range checker, to internalize what ^/~ actually mean:
function satisfiesCaret(version, range) {
const [rMajor, rMinor, rPatch] = range.replace("^", "").split(".").map(Number);
const [vMajor, vMinor, vPatch] = version.split(".").map(Number);
if (vMajor !== rMajor) return false; // major must match exactly
if (vMinor < rMinor) return false;
if (vMinor === rMinor
This is the essential shape of ^'s actual rule: lock the major version, allow anything equal-or-greater within it.
Under the Hood
"exports" and "type" directly control the module-resolution behavior covered in Modules & File System, this topic is where those package.json fields get their dedicated treatment, having only been introduced briefly there. And npm audit, mentioned in Security as a way to catch known dependency vulnerabilities, operates directly against the dependency tree this topic's lockfile discussion describes, the lockfile is what audit actually scans.
Common Mistakes
1. Using npm install in CI instead of npm ci
# CI config
- run: npm install # ❌ can silently update the lockfile mid-build, masking driftCI should use npm ci specifically, its strictness (failing loudly on mismatch, guaranteed clean node_modules) is exactly the property you want in an automated, reproducible build environment.
2. Not committing package-lock.json
# .gitignore
package-lock.json # ❌ defeats the entire purpose of having a lockfileAn uncommitted lockfile means every fresh npm install (including in CI) can resolve different transitive dependency versions, the exact non-reproducibility a lockfile exists to prevent.
3. Assuming ^ ranges never introduce breaking changes
{ "dependencies": { "some-package": "^2.4.0" } }^ relies on the package author correctly following semver, npm cannot enforce that a "minor" release truly contains no breaking changes; a poorly maintained package can break something even within a ^-allowed update.
Best Practices
- Always commit
package-lock.json, it's part of the project's reproducibility guarantee, not a disposable artifact. - Use
npm ciin CI/production builds,npm installfor local development where you're actively adding/updating dependencies. - Understand
"exports"before publishing a package meant to support bothrequire()andimportconsumers,"main"alone doesn't give you that precision. - Pin
"engines"to the actual Node version range your code depends on (e.g., if using a feature confirmed stable only in a specific version), so consumers get an explicit signal rather than a silent incompatibility.
Performance Tips
npm ciis typically faster thannpm installfor a clean install, specifically because it skips the dependency-resolution step entirely, it trusts the lockfile completely rather than re-resolving ranges, which is part of why it's the right default for CI beyond just its strictness.- Workspaces' dependency hoisting (sharing a single top-level
node_modulesacross packages where possible) reduces both install time and disk usage compared to each package in a monorepo maintaining a fully separatenode_modules.
