What package.json actually controls
One JSON file in your project root decides which packages get installed, which Node version is allowed to run the code, what happens when someone types npm run build, and what the registry shows if you publish. Node itself reads only a handful of those keys. The rest are read by npm, by pnpm and yarn, by bundlers, and by your editor. Getting the file right once saves the class of bug where a project installs fine on your laptop and dies in CI.
Every key falls into one of four jobs. Identity keys describe the package to humans and to the registry. Resolution keys tell a loader which file to open. Dependency keys drive the install. Constraint keys refuse work that would break.
| Key | Job | Who reads it | Required |
|---|---|---|---|
name | Identity on disk and in the registry | npm, the resolver | To publish or install |
version | Which release this code is | npm, every range solver | To publish |
main | Entry file for require and legacy imports | Node, bundlers | No, defaults to index.js |
type | Reads .js files as ESM or CommonJS | Node | No, defaults to commonjs |
exports | Locks down which paths outsiders reach | Node 12.7 and newer, bundlers | No, but it overrides main |
scripts | Named shell commands | npm run, pnpm, yarn, bun | No |
dependencies | Installed for everyone who uses the package | Every package manager | No |
devDependencies | Installed only in the package itself | Every package manager | No |
engines | Node and package manager version gate | npm warns, pnpm blocks | No |
private | Refuses npm publish when true | npm | No |
files | Whitelist of what ships in the tarball | npm pack and publish | No |
Version ranges and what the caret really allows
A dependency line is a range, not a pin. The characters in front of the number decide how much drift you accept the next time someone runs an install on a clean checkout. Most published packages sit on a caret, and most of the surprise upgrades people report come from misreading it.
| Range | Reads as | Accepts | Refuses |
|---|---|---|---|
^4.17.1 | Compatible with 4.17.1 | 4.17.2, 4.18.0, 4.99.9 | 5.0.0 |
~4.17.1 | Patch updates only | 4.17.2, 4.17.9 | 4.18.0 |
4.17.1 | Exactly this build | 4.17.1 | Everything else |
>=4.17.1 <5 | Explicit window | Any 4.x at or above 4.17.1 | 5.0.0 |
* | Any published version | All of them | Nothing |
^0.4.2 | Patch only, because major is zero | 0.4.3 | 0.5.0 |
That last row catches experienced developers. Below version 1.0.0 the caret narrows to the behaviour of a tilde, because semver treats a zero major as unstable and lets the minor number carry breaking changes. A package sitting on ^0.x gives you far less freedom than the same caret on ^1.x, and pinning early releases exactly is the safer habit.
The lockfile, not this file, decides the exact version installed. package.json states a range and package-lock.json records the single build that satisfied it. Commit both. Deleting the lockfile to fix an install is the fastest way to turn a working range into a broken one.
Runtime, dev and peer dependencies
Put a package in the wrong bucket and either your users download build tooling they never run, or your production server crashes on a missing module. The rule is short: if the shipped code imports it, it belongs in dependencies.
| Bucket | Installed for consumers | Typical members |
|---|---|---|
dependencies | Yes | express, react, database drivers, anything an import reaches at runtime |
devDependencies | No | vitest, eslint, typescript, bundlers, type definition packages |
peerDependencies | Not installed, only demanded | The framework a plugin attaches to, so one copy of react serves the whole tree |
optionalDependencies | Attempted, failure ignored | Platform specific binaries with a pure JavaScript fallback |
Applications get some slack here. If you deploy a built bundle rather than publishing to the registry, the split matters mostly for install speed and image size. Libraries get none. A TypeScript library that lists typescript under dependencies forces a compiler download on every user who only wanted a function.
Scripts, and the hooks that run without being asked
Anything under scripts becomes npm run name. Four names skip the run word entirely: start, test, stop and restart. The node_modules/.bin folder is prepended to PATH inside a script, so vitest resolves without a relative path or an npx prefix.
"scripts": {"dev": "node --watch server.js","build": "tsup src/index.ts --format esm,cjs --dts","pretest": "eslint .","test": "vitest run","prepublishOnly": "npm run build"}The pre and post prefixes create automatic hooks. Running npm test above fires pretest first, and a failing lint stops the test run before a single spec executes. prepublishOnly fires on publish and nowhere else, which is the right place for a build step you never want running during a normal install.
| Hook | Fires when | Good for |
|---|---|---|
prepare | After install from source, and before publish | Compiling a package installed straight from git |
prepublishOnly | Before npm publish only | Build and test gate on releases |
pre<name> | Before the matching script | Lint before test, clean before build |
post<name> | After the matching script | Size reports, cache warming |
Naming rules that break a publish
The name field has the strictest validation in the file, and npm applies it at publish time rather than at install time. Fixing it late means a rename across your imports and your README.
- Lowercase only. Mixed case names exist in the registry from before the rule landed, and none can be created now.
- 214 characters maximum, counting the scope on a scoped name.
- No leading dot or underscore. Both are reserved by npm for internal use.
- URL safe characters only. Letters, digits, hyphens, dots and underscores. No spaces, no slashes outside a scope.
- No name that reads like an existing one. npm rejects a name a typosquat check flags against a popular package, so
expresandreact-dom-both fail. - Scoped names take a slash, as in
@yourteam/parser. A scope is the only place a slash is legal, and scoped packages publish privately by default unless you pass an access flag.
Module type and the entry point fields
The type field changes how Node reads every .js file in the package. Set it to module and import works while require throws. Leave it out and the opposite holds. File extensions override the setting per file: .mjs is always ESM and .cjs is always CommonJS, whatever type says.
For a library published to the registry, exports replaces main and does more. It maps public paths, hides internals, and hands different files to different loaders.
"type": "module","main": "./dist/index.cjs","exports": {".": {"types": "./dist/index.d.ts","import": "./dist/index.js","require": "./dist/index.cjs"},"./package.json": "./package.json"}Two details bite here. Once exports exists, any path not listed becomes unreachable, so a consumer importing yourpkg/dist/util.js gets a resolution error rather than the file. And types has to come first inside each condition, because the resolver takes the first match and TypeScript never gets a look otherwise. The generator above writes main, which suits applications and small packages. Add exports by hand when you start shipping dual builds.
Where this generator stops
The output covers the fields a normal project sets by hand. Several real fields sit outside it, on purpose.
- No
exports,importsortypesVersionsmap. These are nested conditional structures that a form flattens badly. Write them directly once your build produces more than one artifact. - No workspaces. Monorepo roots need a workspaces array plus per package manifests, and the layout choice belongs to your repository rather than a form.
- No dependency version lookup. Nothing here contacts the registry, so a version you type is a version you get. Run
npm outdatedafter the first install to see what is current. - No name availability check. Validation covers the format rules, not whether the name is already taken.
npm view yournameanswers that in a second. - Overwriting an existing file loses your edits. If the project already has a manifest, copy the fields you want across rather than replacing the file, since your lockfile is tied to what is in there now.
- Nothing is uploaded. The whole page runs in your browser, which also means nothing is saved. Download or copy before you close the tab.
Checking the file before you commit it
Four commands catch nearly every mistake a hand written manifest carries.
- Save the output as
package.jsonin your project root, then runnpm install. A malformed range or an unknown package fails here, loudly. - Run
npm pkg fix. It repairs the repository URL format, normalises the license identifier and corrects a few common shape errors in place. - Run
npm pack --dry-run. It lists exactly which files a publish would ship, which is where an oversized tarball or a missing dist folder shows up. - Run
npm runwith no arguments to print every script name. A typo in a script key is invisible until the moment CI needs it.
For an application rather than a package, add one more habit: commit the lockfile, and run npm ci instead of npm install in your pipeline. The first command respects the lockfile exactly and fails when it disagrees with package.json. The second quietly rewrites the lockfile to match, which is how a green local build turns into a red deploy.
