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:
node -v # v22.11.0npm -v # 10.9.0npm’s version is independent of Node’s. npm install -g npm@latest upgrades the client in place.
package.json
Section titled “package.json”Every Node project has one. It is the manifest: identity, entry points, scripts, and dependencies.
npm init -y # generate a minimal one with defaultsnpm init # interactive{ "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"}Identity fields
Section titled “Identity fields”| Field | Notes |
|---|---|
name |
Lowercase, URL-safe, ≤214 chars. Must be unique on the registry unless scoped (@scope/name). Required to publish. |
version |
Strict semver — MAJOR.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. |
Entry points
Section titled “Entry points”| 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. |
Dependency types
Section titled “Dependency types”{ "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.
Other useful fields
Section titled “Other useful fields”{ "workspaces": ["packages/*"], "packageManager": "pnpm@9.12.0", "engines": { "node": ">=22" }, "overrides": { "semver": "^7.6.0" }, "publishConfig": { "access": "public" }, "sideEffects": false}overridesforces a version of a transitive dependency — the escape hatch for an unpatched vulnerability deep in the tree.packageManagerrecords which tool and version to use; Corepack (bundled with Node, experimental) reads it to install the right one.sideEffects: falsetells bundlers your modules are safe to tree-shake.
Semver
Section titled “Semver”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 operators
Section titled “Range operators”| 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:
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.9The lockfile
Section titled “The lockfile”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.
{ "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 installa 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.
node_modules
Section titled “node_modules”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 initdoes 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’sbinentries. npm puts this directory onPATHwhen 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.
Installing
Section titled “Installing”npm install # install everything from package.json, update the lockfilenpm i # alias
npm install express # add to dependencies, save ^version to package.jsonnpm 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 PATHnpm install express@4.18.2 # a specific versionnpm install express@latest # a dist-tag
npm uninstall express # remove from node_modules and package.jsonnpm install vs npm ci
Section titled “npm install vs npm ci”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 |
npm ci # exact, reproducible, cleannpm ci --omit=dev # skip devDependencies for a production imageIf 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.
Updating
Section titled “Updating”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.jsonrange allows. - Latest — the newest published, ignoring your range.
npm update # move everything to "Wanted" (within range), update the lockfilenpm update express # just onenpm install typescript@latest # cross a major boundary — this is the only waynpm update never crosses a major version, because ^ does not. Major upgrades are deliberate: read the changelog, bump explicitly, run the tests.
Inspecting
Section titled “Inspecting”npm ls express # where does this version come from?npm ls --depth=0 # direct dependencies onlynpm explain express # why is it here? which packages depend on it (alias: npm why)npm view express versions # every published versionnpm view express@latest # metadata for onenpm docs express # open the homepagenpm pkg get version # read a package.json field programmaticallynpm pkg set version=2.0.0 # write oneScripts
Section titled “Scripts”scripts are shell commands with node_modules/.bin prepended to PATH.
{ "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" }}npm run build # run a named scriptnpm run # list all available scriptsnpm test # shorthand; also npm start, npm stop, npm restartnpm run build --silent # suppress npm's own outputPassing arguments
Section titled “Passing arguments”Everything after -- goes to the underlying command:
npm run test -- --watch# runs: node --test --watchThe -- is required for npm run. Forgetting it is the single most common npm-script mistake.
Pre and post hooks
Section titled “Pre and post hooks”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. |
Cross-platform scripts
Section titled “Cross-platform scripts”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.
npx create-vite my-app # download, run, discardnpx eslint . # uses the LOCAL eslint if node_modules has onenpx tsc@5.6.2 --version # pin the versionnpx -y some-tool # skip the "install it?" prompt (for CI)npx --package=@scope/pkg bin-name # when the binary name differs from the packageResolution 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).
Configuration and the registry
Section titled “Configuration and the registry”npm reads .npmrc files, most specific first: project → user (~/.npmrc) → global → built-in.
save-exact=trueengine-strict=trueignore-scripts=true@acme:registry=https://npm.pkg.github.comnpm config list # effective config and where each value came fromnpm config get registry # https://registry.npmjs.org/Key points
Section titled “Key points”package.jsonis identity + entry points + scripts + dependencies;"private": trueon every app.dependenciesship,devDependenciesdo not,peerDependenciesmust be a single shared copy.^1.4.2allows minor and patch updates — except below 1.0.0, where it only allows patch.- Commit
package-lock.json; never commitnode_modules; never hand-edit the lockfile. npm ciin CI,npm installwhile developing.npm outdatedshows Wanted (in-range) vs Latest;npm updateonly reaches Wanted.npm run x -- --flagis how you pass arguments through.postinstallscripts execute third-party code on every install.