package.json Deep Dive
The project manifest
Section titled “The project manifest”package.json is the manifest for every Node.js project. It tells Node and npm who owns the package, what version it is, how to run it, which files are public API, and which packages it depends on. Every npm install, npm run, and npm publish command reads from this file.
Key fields
Section titled “Key fields”| Field | Purpose |
|---|---|
name | The package identifier on the npm registry (lowercase, URL-safe) |
version | The current release, following Semantic Versioning (MAJOR.MINOR.PATCH) |
type | Module system default: "module" for ESM, "commonjs" (or absent) for CJS |
main | Legacy entry point for CJS consumers (require('your-pkg')) |
exports | Modern entry point map — controls what consumers can import and from which subpaths |
scripts | Named shell commands run with npm run <name> (plus lifecycle hooks like start, test) |
Annotated example
Section titled “Annotated example”{ "name": "my-library", "version": "1.2.0", "description": "A small utility library", "type": "module", "main": "./dist/index.cjs", "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" }, "./utils": "./dist/utils.js" }, "scripts": { "build": "tsc", "test": "node --test", "start": "node dist/index.js", "lint": "eslint src" }, "dependencies": { "zod": "^3.22.4" }, "devDependencies": { "typescript": "~5.4.0" }}The exports field takes precedence over main in modern Node.js. It lets you expose multiple subpaths and serve different files to ESM and CJS consumers from the same package.
exports wins over main — proved, not just claimed
Section titled “exports wins over main — proved, not just claimed”The demo below builds a real fake package under node_modules/, giving it a main pointing at one file and an exports pointing at a different one, then asks Node to resolve it:
Output (verified on Node 24)
require.resolve picked: node_modules/demo-pkg/modern.cjs
via: exportsBoth fields are technically valid here — nothing warns about the mismatch — but exports wins every time it is present. This is exactly the failure mode behind the “main still points at src/index.ts” bug: an AI-generated package.json update adds a modern exports map but forgets to also point the legacy main field somewhere shippable, and vice versa.
Semver ranges
Section titled “Semver ranges”npm uses Semantic Versioning for dependency ranges. The range syntax in package.json controls which versions npm install considers compatible.
| Range | Meaning | Example |
|---|---|---|
^1.2.3 | Compatible — allows MINOR and PATCH bumps, locks MAJOR | >=1.2.3 <2.0.0 |
~1.2.3 | Patch-only — allows only PATCH bumps | >=1.2.3 <1.3.0 |
1.2.3 | Exact — only this version | 1.2.3 |
* | Any version — no constraint | >=0.0.0 |
Dependencies block examples
Section titled “Dependencies block examples”{ "dependencies": { "express": "^4.18.2", "dotenv": "~16.3.1", "uuid": "9.0.0", "some-experimental-pkg": "*" }}In practice, ^ is the most common choice for dependencies: it keeps you on the latest compatible release while protecting against breaking changes introduced by a new major version.
^0.x is a trap: caret means patch-only below 1.0.0
Section titled “^0.x is a trap: caret means patch-only below 1.0.0”npm’s semver spec treats a 0.x major version as itself unstable, so ^ on a 0.x version only allows patch bumps — not minor ones. This is the opposite of what most people assume ^ means, and it is easy to miss when skimming a package.json that mixes ^1.x and ^0.x ranges.
npx semver proves it — feed it a range and a list of versions, and it prints only the ones the range allows:
# ^0.3.1 on a *pre-1.0* package: only 0.3.x patches pass, 0.4.0 does NOTnpx semver -r "^0.3.1" 0.3.1 0.3.9 0.4.00.3.10.3.9Compare that with ^1.2.3, where the same caret allows the next minor:
npx semver -r "^1.2.3" 1.2.3 1.9.9 2.0.01.2.31.9.90.4.0 was silently filtered out of the first list — npm install on "^0.3.1" will never move you to 0.4.0, even though it looks like a normal minor bump. If a package you depend on is still on 0.x, treat every release as a potential breaking change regardless of what the caret in your package.json implies.
Initialising a project
Section titled “Initialising a project”npm init walks you through an interactive prompt to generate a package.json. npm init -y skips all prompts and accepts the defaults — useful when you want a minimal file fast.
# Interactive wizardnpm init
# Accept all defaults immediatelynpm init -yAfter running npm init -y you get a minimal package.json in the current directory:
{ "name": "my-project", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC"}Edit the file directly or use npm pkg set name="my-library" type="module" to update individual fields from the terminal.
sideEffects and tree-shaking
Section titled “sideEffects and tree-shaking”Bundlers (webpack, esbuild, Rollup, Vite) can drop unused exports from the final bundle — but only if they can prove that importing a module and never using its exports has no observable effect. A module that runs code at import time purely for a side effect (registering a polyfill, patching a global, injecting CSS) breaks that proof unless the bundler is told about it explicitly.
{ "name": "my-library", "sideEffects": false}"sideEffects": false is a promise to every bundler: “every file in this package is safe to drop if nothing imports from it.” If that promise is false — even for one file — the bundler can silently delete code a consumer actually depended on (a polyfill that runs on import, for example), producing a bug that only shows up in the bundled production build, never in development or in Node directly. The safer, more precise form lists exactly which files are not side-effect-free:
{ "sideEffects": ["./src/polyfills.js", "*.css"]}Node itself does not read "sideEffects" at all — it is a signal exclusively for bundlers. Getting it wrong never breaks node app.js; it only breaks the bundled build, which is exactly why the bug is easy to ship unnoticed.
engines, .nvmrc, and engine-strict
Section titled “engines, .nvmrc, and engine-strict”"engines" declares which Node (and optionally npm) versions a package supports. By itself it is only documentation — npm prints a warning on a mismatch but still installs:
{ "engines": { "node": ">=20.0.0" }}Two things make it enforced instead of advisory:
.nvmrc— a plain-text file (just a version string, e.g.24.13.0) thatnvm usereads to switch the active Node version. It only affects a developer’s shell; it has no effect onnpm installor CI unless a script explicitly runsnvm use.engine-strict=truein.npmrc— turns the"engines"warning into a hard install failure.
engine-strict=trueWith both in place, installing on an incompatible Node version fails loudly instead of installing anyway and breaking at runtime:
# package.json: "engines": { "node": ">=99.0.0" } (deliberately impossible)# .npmrc: engine-strict=truenpm installnpm error code EBADENGINEnpm error engine Unsupported enginenpm error engine Not compatible with your version of node/npm: [email protected]npm error notsup Not compatible with your version of node/npm: [email protected]npm error notsup Required: {"node":">=99.0.0"}npm error notsup Actual: {"node":"v24.13.0","npm":"11.18.0"}Without engine-strict, that exact same mismatch would print a warning and install anyway — the failure only shows up later, as a confusing runtime error on whatever syntax or API the newer engine required.
Mental Model
Section titled “Mental Model”package.json fields fall into two very different categories: some are read by Node or npm at install/run time (type, exports, main, engines with engine-strict, overrides) and enforce something; others are read only by external tooling (sideEffects by bundlers, browser by bundlers, most of scripts by whatever you type after npm run). Reading a field’s name is not enough to know whether getting it wrong breaks node app.js, breaks only a bundled build, or breaks nothing until a human misreads it.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
^ ranges everywhere | Automatic bug-fix and feature updates without touching package.json | On a 0.x dependency, ^ silently narrows to patch-only — easy to misread as “same as any other ^” |
~ ranges | Tighter control, fewer surprise behavior changes | Misses bug fixes that land in a minor release; more manual npm update work |
"sideEffects": false | Smaller bundles via aggressive tree-shaking | Wrong on a package with even one side-effecting file — the bug only appears in bundled builds |
engine-strict=true | Install fails loudly on an incompatible Node version instead of breaking at runtime | Adds friction for anyone on the “wrong” Node version, including CI images that need bumping in lockstep |
Common Misconceptions
Section titled “Common Misconceptions”- “
^always means minor and patch bumps are safe.” Only above1.0.0. On a0.xversion,^allows patch bumps only — npm treats the entire0.xline as unstable by definition. - “
sideEffects: falseis just a hint bundlers might use.” It is a hard promise. A bundler that trusts it will delete files it believes are unused, even if they actually run needed side effects on import. - “
enginesblocks the install on an incompatible Node version by default.” By default it is advisory only — a warning, not a failure. It only becomes a hard failure withengine-strict=truein.npmrc.
Reading AI-generated code
Section titled “Reading AI-generated code”Model training data treats ^0.x ranges exactly like ^1.x ranges — the special case for pre-1.0 versions is a genuine gap in most people’s (and most models’) semver intuition, not a typo. It shows up in generated package.json diffs as a range that looks perfectly normal and is quietly wrong.
🐛 Spot the bug
{"name": "my-cli-tool","version": "1.0.0","dependencies": { "fast-json-schema": "^0.3.1"}}Show the answer
This looks like a normal caret range, but fast-json-schema is still on a 0.x major. For a 0.x dependency, ^ only allows patch bumps (0.3.x) — it will never move to 0.4.0 even though that looks like a routine minor update. If the intent was “any 0.3.x or 0.4.x is fine,” the correct range is ~0.3.1 || ~0.4.0, or simply pin exact and bump deliberately, since pre-1.0 packages make no compatibility promises across minor versions at all.