NodeJSpackage.json Explained

package.json Explained

package.json is the manifest at the heart of every Node project — a single JSON file that declares what your project is (name, version, entry point), what it needs (dependencies), and what it does (scripts). npm, yarn, pnpm, bundlers, and Node itself all read it. Understanding each field turns it from boilerplate into a precise control surface.

A realistic example

package.json

JSON
{
  "name": "@acme/widget-api",
  "version": "2.4.1",
  "description": "REST API for managing widgets",
  "type": "module",
  "main": "./dist/index.js",
  "exports": {
    ".": "./dist/index.js",
    "./client": "./dist/client.js"
  },
  "bin": { "widget": "./bin/cli.js" },
  "engines": { "node": ">=20" },
  "scripts": {
    "dev": "node --watch src/index.js",
    "test": "node --test",
    "build": "tsc -p ."
  },
  "dependencies": { "express": "^4.19.2" },
  "devDependencies": { "typescript": "^5.4.0" },
  "license": "MIT"
}
The identity fields

Field

Purpose

Rules

name

Package identifier

Lowercase, URL-safe, ≤214 chars; may be scoped (@scope/name)

version

Current version

Must be valid semver x.y.z

description

One-line summary

Shown in npm search results

license

Legal terms

An SPDX id like MIT, ISC, Apache-2.0

private

Block publishing

Set true for apps to prevent accidental npm publish

name + version = unique identity
Together, `name` and `version` identify a package on the registry — no two published versions can share both. For apps you never publish, set `"private": true` so a stray `npm publish` is refused, and the `name`/`version` become free-form.
Entry points: main, exports, type

These three fields decide what code loads when someone imports your package and how it is interpreted:

Field

Controls

main

The legacy entry file require()/import resolves to by default

exports

Modern entry map — defines public subpaths and hides everything else

type

"module" = treat .js as ESM; "commonjs" (default) = treat .js as CJS

bin

Maps command names to scripts, installed onto PATH by npm install -g

`exports` is an encapsulation boundary
Once you add an `"exports"` field, consumers can **only** import the subpaths you list — `require('your-pkg/lib/internal.js')` throws `ERR_PACKAGE_PATH_NOT_EXPORTED`, even though the file exists. This is a feature: it lets you refactor internals freely. The `"."` key is the package root. The interplay of `type`, `main`, and `exports` is detailed in [ES Modules](/nodejs/es-modules) and [CommonJS vs ESM](/nodejs/commonjs-vs-esm).
The dependency fields

Field

For

Installed when

dependencies

Code needed at runtime

Always (incl. by consumers of your package)

devDependencies

Tooling: tests, build, lint

Only on npm install in the project itself

peerDependencies

A host package you plug into

Not auto-installed; consumer must provide it

optionalDependencies

Nice-to-have; tolerate failure

Attempted, but install continues if it fails

The crucial dependencies vs devDependencies distinction has its own page: dependencies vs devDependencies. Version ranges like ^4.19.2 are explained in Semantic Versioning.

Scripts

The scripts map defines named commands you run with npm run <name>. A few names are special — start, test, stop, restart — and run without the run keyword (npm test):

Bash
npm run dev      # runs the "dev" script
npm test         # shortcut for the "test" script
npm start        # shortcut for the "start" script

Scripts get node_modules/.bin prepended to PATH, so locally-installed tools run by bare name. Full mechanics — lifecycle hooks, pre/post, passing args with -- — are in npm Scripts.

Constraining the environment

JSON
{
  "engines": { "node": ">=20.0.0", "npm": ">=10" },
  "os": ["!win32"],
  "cpu": ["x64", "arm64"]
}
`engines` warns, it does not enforce — by default
A mismatched `engines.node` only prints a warning unless the user has `engine-strict=true` in their npmrc (or runs `npm install` in a project with `.npmrc` setting it). To *guarantee* a Node version across a team, pair `engines` with a CI check or a tool like Volta. `os` and `cpu` similarly gate where a package installs.
Controlling what gets published

The files array is an allow-list of what ends up in the published tarball — usually just your build output, never source or tests:

JSON
{
  "files": ["dist", "README.md"],
  "main": "./dist/index.js"
}
`files` allow-list vs `.npmignore` deny-list
If `files` is present, npm publishes *only* what it lists (plus always-included files like `package.json`, `README`, `LICENSE`). Without it, npm includes everything except what `.npmignore`/`.gitignore` excludes. The allow-list is safer — you cannot accidentally ship secrets or a 200 MB `node_modules`. Verify before publishing with `npm pack --dry-run`. More in [Publishing Packages](/nodejs/publishing-packages).
Editing it: by hand vs by CLI
  • Run npm install <pkg> / npm uninstall <pkg> — npm edits dependencies and the lockfile for you. Prefer this over hand-editing versions.

  • Run npm pkg set scripts.lint="eslint ." to script edits without opening the file.

  • Hand-edit freely for scripts, exports, engines, metadata — just keep it valid JSON (no comments, no trailing commas).

  • Run npm pkg get dependencies to read fields programmatically.

It is strict JSON — not JavaScript
`package.json` forbids comments and trailing commas. A single stray comma makes every npm command fail with a parse error. If you need to annotate, some teams add a throwaway `"//": "note"` key (JSON allows duplicate-looking string keys as values), but the cleanest answer is to keep notes in the README.
Next
The version numbers in `dependencies` are only half the story — the exact tree that actually installs lives in the lockfile: [Understanding package-lock.json](/nodejs/package-lock).