Skip to content

package.json Deep Dive

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.

FieldPurpose
nameThe package identifier on the npm registry (lowercase, URL-safe)
versionThe current release, following Semantic Versioning (MAJOR.MINOR.PATCH)
typeModule system default: "module" for ESM, "commonjs" (or absent) for CJS
mainLegacy entry point for CJS consumers (require('your-pkg'))
exportsModern entry point map — controls what consumers can import and from which subpaths
scriptsNamed shell commands run with npm run <name> (plus lifecycle hooks like start, test)
{
"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:

Node.js
Output (verified on Node 24)
require.resolve picked: node_modules/demo-pkg/modern.cjs
via: exports

Both 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.

npm uses Semantic Versioning for dependency ranges. The range syntax in package.json controls which versions npm install considers compatible.

RangeMeaningExample
^1.2.3Compatible — allows MINOR and PATCH bumps, locks MAJOR>=1.2.3 <2.0.0
~1.2.3Patch-only — allows only PATCH bumps>=1.2.3 <1.3.0
1.2.3Exact — only this version1.2.3
*Any version — no constraint>=0.0.0
{
"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:

Terminal window
# ^0.3.1 on a *pre-1.0* package: only 0.3.x patches pass, 0.4.0 does NOT
npx semver -r "^0.3.1" 0.3.1 0.3.9 0.4.0
0.3.1
0.3.9

Compare that with ^1.2.3, where the same caret allows the next minor:

Terminal window
npx semver -r "^1.2.3" 1.2.3 1.9.9 2.0.0
1.2.3
1.9.9

0.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.

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.

Terminal window
# Interactive wizard
npm init
# Accept all defaults immediately
npm init -y

After 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.

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" 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) that nvm use reads to switch the active Node version. It only affects a developer’s shell; it has no effect on npm install or CI unless a script explicitly runs nvm use.
  • engine-strict=true in .npmrc — turns the "engines" warning into a hard install failure.
.npmrc
engine-strict=true

With both in place, installing on an incompatible Node version fails loudly instead of installing anyway and breaking at runtime:

Terminal window
# package.json: "engines": { "node": ">=99.0.0" } (deliberately impossible)
# .npmrc: engine-strict=true
npm install
npm error code EBADENGINE
npm error engine Unsupported engine
npm 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.

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.

ChoiceBenefitCost
^ ranges everywhereAutomatic bug-fix and feature updates without touching package.jsonOn a 0.x dependency, ^ silently narrows to patch-only — easy to misread as “same as any other ^”
~ rangesTighter control, fewer surprise behavior changesMisses bug fixes that land in a minor release; more manual npm update work
"sideEffects": falseSmaller bundles via aggressive tree-shakingWrong on a package with even one side-effecting file — the bug only appears in bundled builds
engine-strict=trueInstall fails loudly on an incompatible Node version instead of breaking at runtimeAdds friction for anyone on the “wrong” Node version, including CI images that need bumping in lockstep
  • “^ always means minor and patch bumps are safe.” Only above 1.0.0. On a 0.x version, ^ allows patch bumps only — npm treats the entire 0.x line as unstable by definition.
  • “sideEffects: false is 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.
  • “engines blocks 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 with engine-strict=true in .npmrc.

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.

Which package.json field is recommended over "main" in modern Node.js for controlling what a package exposes?
You want to allow patch-level updates to a dependency but prevent minor or major bumps. Which semver range prefix should you use?
What does "npm init -y" do?
A consumer runs require('demo-pkg'). Which file does the import actually resolve to?
{
  "name": "demo-pkg",
  "main": "./legacy.cjs",
  "exports": "./modern.cjs"
}
A dependency is still on version 0.x. Its package.json range is "^0.3.1". Which of these versions does npm consider compatible for npm install?