Skip to content

npm and package.json

npm is two things: a public registry of JavaScript packages at registry.npmjs.org, and the command-line client that installs from it. The client ships with Node, so if you have node you have npm.

Check both:

Terminal window
node -v # v22.11.0
npm -v # 10.9.0

npm’s version is independent of Node’s. npm install -g npm@latest upgrades the client in place.

Every Node project has one. It is the manifest: identity, entry points, scripts, and dependencies.

Terminal window
npm init -y # generate a minimal one with defaults
npm init # interactive
package.json
{
"name": "@acme/widget",
"version": "1.4.2",
"description": "A widget.",
"type": "module",
"main": "./dist/index.cjs",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"bin": { "widget": "./bin/cli.js" },
"files": ["dist", "README.md"],
"engines": { "node": ">=22" },
"scripts": {
"build": "tsc",
"test": "node --test",
"start": "node dist/index.js"
},
"dependencies": { "zod": "^3.23.8" },
"devDependencies": { "typescript": "^5.6.2" },
"peerDependencies": { "react": ">=18" },
"license": "MIT"
}
Field Notes
name Lowercase, URL-safe, ≤214 chars. Must be unique on the registry unless scoped (@scope/name). Required to publish.
version Strict semverMAJOR.MINOR.PATCH. Required to publish.
description, keywords Shown and searched on npmjs.com.
license An SPDX identifier such as MIT, Apache-2.0, or UNLICENSED.
private true prevents accidental publishing. Set it on every application.
repository, homepage, bugs Links shown on the package page.
Field Purpose
type "module" makes .js files ESM. Absent or "commonjs" makes them CommonJS. See modules.
main Legacy entry point. Still read by older tooling and by Node when there is no exports.
exports Modern entry-point map. Supports conditions (import/require/types/node/browser) and multiple subpaths. Acts as an allowlist — unlisted files cannot be imported.
types / typings The .d.ts for TypeScript consumers when there is no exports.
bin Maps command names to script files. Installing the package puts them on PATH.
files Allowlist of what goes into the published tarball.
{
"dependencies": { "express": "^4.19.2" },
"devDependencies": { "vitest": "^2.1.1" },
"peerDependencies": { "react": ">=18" },
"peerDependenciesMeta": { "react": { "optional": true } },
"optionalDependencies": { "fsevents": "^2.3.3" }
}
Type Installed when your package is a dependency? Use for
dependencies Yes Anything imported by your shipped code at runtime.
devDependencies No Test frameworks, bundlers, linters, type definitions, build tools.
peerDependencies Not installed, but warned or errored about if missing or mismatched A framework your plugin extends and must share a single copy of — React, ESLint, Vite.
optionalDependencies Yes, but a failed install is not fatal Platform-specific native binaries.

The peer-dependency rule in one sentence: use it when having two copies of the package in one process would break things. Two copies of React means two hook dispatchers and instant runtime errors — so React is a peer dependency of every React component library.

For an application the dev/prod split matters mainly for npm ci --omit=dev in a production image — smaller and less to audit. For a published library it is load-bearing: anything misfiled as a dev dependency will be missing for your users.

{
"workspaces": ["packages/*"],
"packageManager": "pnpm@9.12.0",
"engines": { "node": ">=22" },
"overrides": { "semver": "^7.6.0" },
"publishConfig": { "access": "public" },
"sideEffects": false
}
  • overrides forces a version of a transitive dependency — the escape hatch for an unpatched vulnerability deep in the tree.
  • packageManager records which tool and version to use; Corepack (bundled with Node, experimental) reads it to install the right one.
  • sideEffects: false tells bundlers your modules are safe to tree-shake.

Semantic versioning is MAJOR.MINOR.PATCH, and the promise attached to each part is what makes ranges safe:

Bump Meaning
PATCH (1.4.2 → 1.4.3) Bug fix. No API change.
MINOR (1.4.2 → 1.5.0) New functionality, backwards compatible.
MAJOR (1.4.2 → 2.0.0) Breaking change.

Pre-release versions look like 2.0.0-beta.1 and sort before 2.0.0. They are never matched by a normal range unless you ask for them explicitly.

Range Matches Notes
1.4.2 exactly 1.4.2 Pinned.
^1.4.2 >=1.4.2 <2.0.0 npm’s default. “Compatible with.”
~1.4.2 >=1.4.2 <1.5.0 Patch updates only.
>=1.4.2 <2 as written Explicit range.
1.4.x, 1.x, * wildcard * means any version.
1.2.7 || >=1.2.9 <2.0.0 union Comma-free `
latest, next dist-tags Resolved at install time, not a range.
file:../local-pkg a local path Handy for testing before publishing.
github:user/repo#branch a git source Works, but no registry integrity guarantees.

The ^ operator has a special case below 1.0.0, because 0.x is officially unstable:

^1.4.2 → >=1.4.2 <2.0.0 (major is pinned)
^0.4.2 → >=0.4.2 <0.5.0 (MINOR is pinned)
^0.0.2 → >=0.0.2 <0.0.3 (exact)

Check any range against any version:

Terminal window
npx semver -r "^0.4.2" 0.5.0
# (no output — does not satisfy)
npx semver -r "^0.4.2" 0.4.9
# 0.4.9

package.json says "express": "^4.19.2". That matches thousands of possible trees. package-lock.json records the exact one that was installed: every package, its exact version, its resolved URL, and an integrity hash of its tarball.

package-lock.json (excerpt)
{
"lockfileVersion": 3,
"packages": {
"node_modules/express": {
"version": "4.19.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
"integrity": "sha512-<base64 hash of the tarball>",
"dependencies": { "accepts": "~1.3.8", "...": "..." }
}
}
}

The integrity field is a Subresource Integrity hash. npm verifies every downloaded tarball against it, so a tampered or swapped package fails the install rather than silently executing.

Commit the lockfile. Always, for applications and libraries alike.

  • Without it, two developers running npm install a week apart get different trees, and “works on my machine” becomes a real bug class.
  • With it, CI installs byte-identical dependencies to your laptop.
  • It makes dependency changes visible in code review — a diff that adds 400 packages is worth a conversation.

The one nuance: a library’s lockfile does not affect its consumers. Their resolver builds its own tree from your package.json ranges. Commit it anyway, so your CI and contributors are reproducible.

Where installed packages live. Notable properties:

  • Never commit it. It is derivable from the lockfile, huge, and often contains platform-specific compiled binaries. Put it in .gitignore (npm init does not do this for you).
  • It is flat-ish: npm hoists dependencies to the top level where versions permit, and nests them only when two packages need incompatible versions of the same thing. This keeps the tree shallow and deduplicated.
  • node_modules/.bin/ holds symlinks to every dependency’s bin entries. npm puts this directory on PATH when running scripts, which is why "test": "vitest" works without a global install.
  • Deleting it is always safe: rm -rf node_modules && npm ci.

The consequences of flattening — phantom dependencies and duplication — are the subject of npm vs pnpm.

Terminal window
npm install # install everything from package.json, update the lockfile
npm i # alias
npm install express # add to dependencies, save ^version to package.json
npm install -D typescript # add to devDependencies (--save-dev)
npm install -E zod # save an exact version, no ^ (--save-exact)
npm install -g pnpm # install globally, onto PATH
npm install express@4.18.2 # a specific version
npm install express@latest # a dist-tag
npm uninstall express # remove from node_modules and package.json
npm install npm ci
Reads package.json + lockfile lockfile only
Lockfile Updates it if ranges allow Never writes it; errors if it disagrees with package.json
node_modules Updates in place Deletes it first, installs clean
Speed Slower (resolution) Faster (no resolution)
Use in Development CI, Docker builds, deploys
Terminal window
npm ci # exact, reproducible, clean
npm ci --omit=dev # skip devDependencies for a production image

If npm ci fails with “lock file does not match package.json”, someone edited package.json by hand without reinstalling. Run npm install locally and commit the updated lockfile.

Terminal window
npm outdated
# Package Current Wanted Latest Location
# express 4.18.2 4.19.2 4.19.2 node_modules/express
# typescript 5.4.5 5.4.5 5.6.2 node_modules/typescript
  • Current — what is installed.
  • Wanted — the newest version your package.json range allows.
  • Latest — the newest published, ignoring your range.
Terminal window
npm update # move everything to "Wanted" (within range), update the lockfile
npm update express # just one
npm install typescript@latest # cross a major boundary — this is the only way

npm update never crosses a major version, because ^ does not. Major upgrades are deliberate: read the changelog, bump explicitly, run the tests.

Terminal window
npm ls express # where does this version come from?
npm ls --depth=0 # direct dependencies only
npm explain express # why is it here? which packages depend on it (alias: npm why)
npm view express versions # every published version
npm view express@latest # metadata for one
npm docs express # open the homepage
npm pkg get version # read a package.json field programmatically
npm pkg set version=2.0.0 # write one

scripts are shell commands with node_modules/.bin prepended to PATH.

package.json
{
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "node --watch --env-file=.env src/index.js",
"test": "node --test",
"lint": "eslint .",
"check": "npm run lint && npm run test",
"prepare": "husky"
}
}
Terminal window
npm run build # run a named script
npm run # list all available scripts
npm test # shorthand; also npm start, npm stop, npm restart
npm run build --silent # suppress npm's own output

Everything after -- goes to the underlying command:

Terminal window
npm run test -- --watch
# runs: node --test --watch

The -- is required for npm run. Forgetting it is the single most common npm-script mistake.

For any script x, npm runs prex before and postx after, automatically.

{
"scripts": {
"prebuild": "rm -rf dist",
"build": "tsc",
"postbuild": "cp README.md dist/"
}
}

npm run build runs all three, in order, and stops at the first failure.

Lifecycle hooks npm calls on its own:

Hook When
preinstall, install, postinstall During npm install of this package.
prepare After npm install in the project, and before npm publish, and after a git-source install. The right place for build steps and git hook setup.
prepublishOnly Before npm publish only — not on install.
prepack / postpack Around tarball creation.

Script bodies run through the platform shell (sh on Unix, cmd.exe on Windows), so rm -rf, && chains with Unix tools, and FOO=bar cmd are not portable. Options: use rimraf and cross-env from npm, or move the logic into a .js file and run node scripts/build.js.

npx runs a package’s binary without installing it permanently.

Terminal window
npx create-vite my-app # download, run, discard
npx eslint . # uses the LOCAL eslint if node_modules has one
npx tsc@5.6.2 --version # pin the version
npx -y some-tool # skip the "install it?" prompt (for CI)
npx --package=@scope/pkg bin-name # when the binary name differs from the package

Resolution order: a local node_modules/.bin entry first, then PATH, then download to a cache and run. That local-first behaviour is the point — npx eslint in a project always uses that project’s ESLint version.

pnpm’s equivalents are pnpm exec (local only) and pnpm dlx (always fetch).

npm reads .npmrc files, most specific first: project → user (~/.npmrc) → global → built-in.

.npmrc
save-exact=true
engine-strict=true
ignore-scripts=true
@acme:registry=https://npm.pkg.github.com
Terminal window
npm config list # effective config and where each value came from
npm config get registry # https://registry.npmjs.org/
  • package.json is identity + entry points + scripts + dependencies; "private": true on every app.
  • dependencies ship, devDependencies do not, peerDependencies must be a single shared copy.
  • ^1.4.2 allows minor and patch updates — except below 1.0.0, where it only allows patch.
  • Commit package-lock.json; never commit node_modules; never hand-edit the lockfile.
  • npm ci in CI, npm install while developing.
  • npm outdated shows Wanted (in-range) vs Latest; npm update only reaches Wanted.
  • npm run x -- --flag is how you pass arguments through.
  • postinstall scripts execute third-party code on every install.