cheat sheet

package.json Reference

Complete reference for the Node.js package manifest — name, version, scripts, dependencies, exports, workspaces, and the packageManager field for Corepack.

package.json Reference

What it is

package.json is the manifest file for every Node.js project and npm package. It declares:

  • Package identity (name, version)
  • Entry points (main, module, exports, bin)
  • Module system (type)
  • Lifecycle and custom scripts (scripts)
  • Runtime and development dependencies
  • Publishing metadata (keywords, license, repository)
  • Monorepo membership (workspaces)

Every npm install, pnpm add, yarn add, and node --require decision ultimately flows through this file.

Minimal example

json
{
  "name": "my-app",
  "version": "1.0.0",
  "description": "A minimal Node.js app",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "^4.19.2"
  },
  "devDependencies": {
    "typescript": "^5.5.2"
  },
  "license": "MIT"
}

Essential identity fields

json
{
  "name": "@my-org/my-lib",    // scoped package name (lowercase, URL-safe)
  "version": "2.3.1",          // semver: MAJOR.MINOR.PATCH
  "description": "One sentence describing the package",
  "keywords": ["http", "framework", "server"],
  "license": "MIT",
  "private": true               // prevents accidental npm publish
}
  • name must be unique on npm if you intend to publish. Scoped names (@scope/name) group related packages.
  • version follows semver. Bump with npm version patch|minor|major.
  • Set "private": true for applications that should never be published to npm.

Entry point fields

json
{
  "main": "./dist/index.js",       // CJS entry point (all Node versions)
  "module": "./dist/index.esm.js", // ESM entry point (bundlers like Webpack/Rollup)
  "types": "./dist/index.d.ts",    // TypeScript type declarations
  "bin": {
    "mycli": "./dist/cli.js"       // name → script; installs symlink in node_modules/.bin
  }
}

"module" is not a Node.js field — it is a bundler convention. For Node.js ESM use "exports" with "import" conditions instead.

The type field

json
{
  "type": "module"      // all .js files in this package are treated as ESM
}
json
{
  "type": "commonjs"    // (default) all .js files are treated as CJS
}

Regardless of "type", you can always force the module system with file extensions:

  • .mjs — always ESM
  • .cjs — always CommonJS

The exports field (modern entry points)

"exports" supersedes "main" for Node.js 12+ and supports conditional exports, subpath exports, and import blocking.

json
{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",    // ESM import
      "require": "./dist/index.cjs",   // CJS require()
      "types": "./dist/index.d.ts",    // TypeScript
      "default": "./dist/index.cjs"    // fallback
    },
    "./utils": {
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.cjs"
    },
    "./package.json": "./package.json"  // allow reading the manifest
  }
}

Subpath patterns (glob):

json
{
  "exports": {
    "./components/*": {
      "import": "./dist/components/*.mjs",
      "require": "./dist/components/*.cjs"
    }
  }
}

Once "exports" is defined, only the listed paths are accessible to consumers. Deep imports like import 'my-pkg/dist/internal' will throw unless explicitly exported.

Conditional export conditions (full list):

ConditionWhen it applies
"import"import / import() statements
"require"require() calls
"node"Node.js runtime
"node-addons"Node.js with native addons
"browser"Browser-targeting bundlers
"deno"Deno runtime
"worker"Web Worker / Service Worker
"default"Catch-all fallback (must be last)

The engines field

json
{
  "engines": {
    "node": ">=20.0.0",
    "npm": ">=10.0.0"
  }
}

npm warns if a consumer's environment does not satisfy these ranges. Set "engine-strict": true in .npmrc to make mismatches fatal.

Scripts

json
{
  "scripts": {
    "preinstall": "node scripts/check-node-version.js",
    "install":    "node-gyp build",
    "postinstall": "patch-package",

    "prestart": "node scripts/pre-flight.js",
    "start":    "node dist/server.js",

    "prebuild": "rimraf dist",
    "build":    "tsc",
    "postbuild": "node scripts/copy-assets.js",

    "dev":      "tsx watch src/index.ts",
    "test":     "vitest run",
    "test:watch": "vitest",
    "lint":     "eslint src --ext .ts,.tsx",
    "format":   "prettier --write src",
    "typecheck": "tsc --noEmit",
    "ci":       "pnpm run typecheck && pnpm run lint && pnpm run test"
  }
}

Lifecycle hooks run automatically:

  • pre<name> runs before <name>
  • post<name> runs after <name>
  • preinstall, install, postinstall — triggered by npm install
  • prepublishOnly, publish, postpublish — triggered by npm publish

Chain multiple commands in one script:

json
{
  "scripts": {
    "build": "tsc && rollup -c && node scripts/minify.js"
  }
}

Access package.json fields inside scripts via npm_package_* environment variables:

bash
# Inside a script, $npm_package_version equals the version field
echo "Building v$npm_package_version"

Output: (none — exits 0 on success)

Dependency types

json
{
  "dependencies": {
    "express": "^4.19.2"
  },
  "devDependencies": {
    "typescript": "^5.5.2",
    "vitest": "^2.0.0"
  },
  "peerDependencies": {
    "react": ">=17.0.0 <20"
  },
  "peerDependenciesMeta": {
    "react": { "optional": true }
  },
  "optionalDependencies": {
    "fsevents": "^2.3.3"
  },
  "bundleDependencies": ["my-bundled-lib"]
}
TypeInstalled forWhen to use
dependenciesApp + library consumersRuntime requirements
devDependenciesApp onlyBuild tools, testing, linters
peerDependenciesConsumer must installPlugin host requirements
optionalDependenciesApp only, failure OKPlatform-specific native modules
bundleDependenciesBundled into tarballWhen you can't rely on npm registry

Version specifiers

json
{
  "dependencies": {
    "exact":       "1.2.3",         // exactly 1.2.3
    "caret":       "^1.2.3",        // >=1.2.3 <2.0.0 (compatible with 1.x)
    "tilde":       "~1.2.3",        // >=1.2.3 <1.3.0 (patch updates only)
    "range":       ">=1.2.3 <2",    // explicit range
    "wildcard":    "*",             // any version (not recommended)
    "latest":      "latest",        // whatever npm considers latest
    "pre-release": "2.0.0-beta.1",  // exact pre-release
    "github":      "github:user/repo#main",
    "git-url":     "git+https://github.com/user/repo.git#v1.2.3",
    "local":       "file:../my-local-pkg"
  }
}

Use ^ (caret) for almost everything. It allows minor and patch updates but not breaking major changes. Use ~ only when you have known instability in minor releases.

workspaces — monorepo support

json
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "packages/*",
    "apps/*",
    "!packages/deprecated-*"
  ]
}

npm, Yarn, and pnpm all read this field (pnpm also uses pnpm-workspace.yaml). Workspaces are hoisted and cross-linked automatically.

files — control what gets published

json
{
  "files": [
    "dist",
    "src",
    "!src/**/*.test.ts",
    "README.md"
  ]
}

Only listed files are included in the npm tarball. package.json, README, CHANGELOG, and LICENSE are always included. node_modules and files in .gitignore are always excluded.

packageManager — Corepack integration

json
{
  "packageManager": "pnpm@9.12.1+sha256.a7e4e2b7"
}

When this field is set, Corepack enforces that the specified manager and version is used. Running npm install in a pnpm project will print an error and exit. This prevents the classic "why does CI fail? you ran npm, not pnpm" problem.

repository, bugs, homepage

json
{
  "repository": {
    "type": "git",
    "url": "https://github.com/my-org/my-repo.git"
  },
  "bugs": {
    "url": "https://github.com/my-org/my-repo/issues",
    "email": "bugs@example.com"
  },
  "homepage": "https://my-org.github.io/my-repo"
}

Complete maximal example

json
{
  "name": "@my-org/my-lib",
  "version": "3.2.1",
  "description": "A production-grade example library",
  "keywords": ["utility", "typescript", "esm"],
  "license": "MIT",
  "author": {
    "name": "Alice Dev",
    "email": "alice@example.com",
    "url": "https://alice.example.com"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/my-org/my-lib.git"
  },
  "bugs": { "url": "https://github.com/my-org/my-lib/issues" },
  "homepage": "https://my-org.github.io/my-lib",
  "packageManager": "pnpm@9.12.1",
  "engines": { "node": ">=20.0.0" },
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./utils": {
      "import": "./dist/utils.js",
      "require": "./dist/utils.cjs",
      "types": "./dist/utils.d.ts"
    }
  },
  "bin": { "my-cli": "./dist/cli.js" },
  "files": ["dist", "README.md", "CHANGELOG.md"],
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts",
    "dev": "tsup src/index.ts --watch",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "eslint src",
    "typecheck": "tsc --noEmit",
    "prepublishOnly": "pnpm run build && pnpm run test"
  },
  "dependencies": {
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "typescript": "^5.5.2",
    "vitest": "^2.0.0",
    "tsup": "^8.2.4",
    "eslint": "^9.7.0"
  },
  "peerDependencies": {
    "typescript": ">=5.0.0"
  },
  "peerDependenciesMeta": {
    "typescript": { "optional": true }
  }
}

bin — installing CLI entry points

A package can ship one or more executables via the bin field. When the package is installed, the package manager creates symlinks (or shim scripts on Windows) in node_modules/.bin/ and — for global installs — in the system PATH (e.g. /usr/local/bin/ or %APPDATA%\npm\).

json
{
  "name": "mytool",
  "version": "1.0.0",
  "bin": "./dist/cli.js"
}

Single-string form: the symlink is named after the package (mytool). Multi-entry form maps names to scripts:

json
{
  "bin": {
    "mytool": "./dist/cli.js",
    "mytool-fast": "./dist/cli-fast.js"
  }
}

The target file must start with a shebang line and be executable:

javascript
#!/usr/bin/env node
import { program } from "commander";
program.parse();
bash
chmod +x dist/cli.js   # not required for npm; some package managers preserve mode

Output: (none — exits 0 on success)

Use npx to run a bin from an installed-but-not-PATH package: npx mytool --help runs node_modules/.bin/mytool.

imports — internal subpath aliases

"imports" is the inverse of "exports" — it defines aliases for the package's own internal imports, scoped to the package. The convention is to prefix aliases with # to mark them as private. Use this to avoid deep ../../../utils relative paths.

json
{
  "imports": {
    "#utils/*": "./src/utils/*.js",
    "#config": {
      "node": "./src/config.node.js",
      "browser": "./src/config.browser.js",
      "default": "./src/config.js"
    }
  }
}
javascript
// src/server.js — inside the same package
import { log } from "#utils/log.js";
import config from "#config";

This works in plain Node (no bundler needed) and is the standards-track replacement for module-alias and tsconfig-paths runtime hacks. Note that TypeScript still needs paths in tsconfig.json to resolve these for type-checking.

exports — advanced conditional resolution

The earlier section covered the basics. Here are the patterns real packages use.

Per-condition + per-environment fallback chain

json
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "browser": {
        "import": "./dist/index.browser.mjs",
        "require": "./dist/index.browser.cjs"
      },
      "node": {
        "import": "./dist/index.node.mjs",
        "require": "./dist/index.node.cjs"
      },
      "default": "./dist/index.js"
    }
  }
}

The resolver walks top-to-bottom and picks the first matching condition. "types" must come first for TypeScript to resolve correctly. "default" must come last as the catch-all.

Pattern exports (glob)

json
{
  "exports": {
    "./components/*": {
      "types": "./dist/components/*.d.ts",
      "import": "./dist/components/*.js"
    }
  }
}

Consumers can then import Button from "my-lib/components/Button". Each request gets resolved by string substitution.

Block deep imports explicitly

json
{
  "exports": {
    ".": "./dist/index.js"
  }
}

With just ".", every other import (my-lib/utils, my-lib/internal) throws ERR_PACKAGE_PATH_NOT_EXPORTED. This is the modern way to enforce "public API only" boundaries on consumers.

package.json itself

If consumers need to read your package.json (e.g. for the version), expose it explicitly:

json
{
  "exports": {
    ".": "./dist/index.js",
    "./package.json": "./package.json"
  }
}

scripts — lifecycle deep dive

npm runs lifecycle scripts implicitly during certain operations. Custom scripts (npm run <name>) also auto-trigger their pre<name> and post<name> hooks. Understanding which trigger when prevents "why didn't my install hook run?" mysteries.

Lifecycle order (npm install)

text
preinstall          → before any deps are resolved
install             → during install (rare; legacy node-gyp packages)
postinstall         → after deps installed (run codegen, patches)
prepublish          → DEPRECATED — runs on `npm install` AND publish
prepare             → runs after install + before publish + on git deps
prepublishOnly      → runs only on `npm publish`, after `prepare`
prepack             → runs before `npm pack` (tarball creation)
postpack            → runs after `npm pack`

prepare is the modern hook for "build before publishing" — it runs reliably on git-installed deps (where the registry tarball doesn't exist), so a consumer who does npm install github:my-org/repo still gets a built copy.

json
{
  "scripts": {
    "prepare": "npm run build",
    "prepublishOnly": "npm test && npm run lint"
  }
}

Pre/post hooks for arbitrary scripts

json
{
  "scripts": {
    "prebuild": "rimraf dist",
    "build": "tsc",
    "postbuild": "node scripts/copy-assets.js"
  }
}

Running npm run build runs prebuild → build → postbuild automatically. There is no way to disable this (other than renaming the script).

Cross-platform script runners

Bash-specific scripts break on Windows. Use cross-platform tools:

json
{
  "scripts": {
    "clean": "rimraf dist coverage",                  // cross-platform rm -rf
    "copy": "cpy 'src/assets/**/*' dist/assets",      // cross-platform cp
    "envset": "cross-env NODE_ENV=production node ./dist/server.js",
    "parallel": "npm-run-all --parallel dev:server dev:client",
    "sequential": "npm-run-all build:lib build:cli"
  }
}
ToolPurpose
rimrafCross-platform rm -rf
cpy-cliCross-platform file copy
cross-envSet env vars across Windows/POSIX
npm-run-all / concurrentlyRun scripts in parallel or sequence
wait-onWait for URL/file before next step

npm_* env vars

Inside any script, npm exposes config and package metadata as env vars:

bash
echo "Package: $npm_package_name"
echo "Version: $npm_package_version"
echo "Node: $npm_config_user_agent"

Output:

text
Package: my-app
Version: 1.0.0
Node: npm/10.8.1 node/v20.15.0 darwin arm64

-- to forward arguments

bash
npm run test -- --watch --coverage

Output: (Vitest receives --watch --coverage)

The -- separator passes everything after to the underlying script. Without it, the flags bind to npm itself.

workspaces — monorepo deep dive

Workspaces wire multiple packages into one install. Every workspace package gets its own package.json; dependencies are hoisted to the root node_modules/ when compatible, and cross-package references are symlinks.

json
// Root package.json
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}
text
my-monorepo/
├── package.json           ← workspaces declared here
├── node_modules/          ← hoisted deps (single copy of shared packages)
├── packages/
│   ├── ui/
│   │   ├── package.json   ← name: "@my/ui"
│   │   └── src/
│   └── utils/
│       ├── package.json   ← name: "@my/utils"
│       └── src/
└── apps/
    └── web/
        ├── package.json   ← deps: { "@my/ui": "workspace:*" }
        └── src/

Workspace protocols (pnpm/Yarn/npm)

pnpm and Yarn support workspace:* (any version), workspace:^ (with caret), workspace:1.2.3 (exact). npm uses plain version specifiers and symlinks if it finds a matching workspace.

json
// apps/web/package.json
{
  "dependencies": {
    "@my/ui": "workspace:*",
    "@my/utils": "workspace:^"
  }
}

Running scripts across workspaces

bash
# npm — run "build" in every workspace
npm run build --workspaces

# Skip workspaces missing the script
npm run test --workspaces --if-present

# Only one workspace
npm run dev --workspace=@my/ui

# pnpm — recursive
pnpm -r build
pnpm --filter @my/ui dev

# Yarn
yarn workspaces foreach -A run build

Output: (each workspace's script runs in turn or in parallel depending on flags)

Hoisting and nohoist

The default behaviour is to hoist every dep to root. When a workspace needs a specific version that conflicts with hoisting, pnpm and Yarn place a private copy under the workspace's own node_modules/. npm 7+ uses an installed-tree model that is mostly equivalent.

json
// Yarn — nohoist specific packages
{
  "workspaces": {
    "packages": ["packages/*"],
    "nohoist": ["**/react-native"]
  }
}

engines — runtime requirements

json
{
  "engines": {
    "node": ">=20.0.0",
    "npm": ">=10.0.0",
    "pnpm": ">=9.0.0",
    "bun": ">=1.0.0"
  }
}

By default this is a warning. Make it fatal via .npmrc:

text
# .npmrc
engine-strict=true

Output:

text
npm ERR! code EBADENGINE
npm ERR! Unsupported engine {
npm ERR!   package: 'my-app@1.0.0',
npm ERR!   required: { node: '>=20.0.0' },
npm ERR!   current:  { node: 'v18.20.4', npm: '10.7.0' }
npm ERR! }

pnpm honours engines unconditionally — no strict flag needed.

packageManager — Corepack

Corepack ships with Node 16.10+ and reads packageManager from package.json to install and pin the exact version of npm, pnpm, Yarn, or Bun used by the project.

json
{
  "packageManager": "pnpm@9.12.1+sha256.a7e4e2b7c4b9c4d5e6f7a8b9c0d1e2f3"
}

Enable Corepack once per machine:

bash
corepack enable

Output:

text
Internal Error: EEXIST: file already exists
(safe — Corepack symlinks already in place)

After enabling, running pnpm / yarn / npm in a repo first reads packageManager, downloads the pinned version into ~/.cache/node/corepack/, and invokes that exact version. Mismatched commands abort:

Output:

text
This project is configured to use pnpm because packageManager is "pnpm@9.12.1"
Please use pnpm instead of npm to run scripts.

This eliminates the "works on my machine because I have npm 10, you have npm 7" class of bug.

Overrides — forcing dependency versions

Sometimes a transitive dependency has a bug or vulnerability that the direct dep hasn't picked up. Overrides force a specific version across the tree.

npm — overrides

json
{
  "overrides": {
    "vulnerable-pkg": "1.2.4",
    "react": "$react",                              // pin to your own version
    "some-pkg": {
      "transitive-dep": "^2.0.0"
    },
    "foo@<1.0.0": {
      "bar": "1.0.0"
    }
  }
}

The $name syntax reuses your own dependencies entry — handy for ensuring a single React copy across the tree.

Yarn — resolutions

json
{
  "resolutions": {
    "vulnerable-pkg": "1.2.4",
    "**/vulnerable-pkg": "1.2.4",
    "some-pkg/transitive-dep": "^2.0.0"
  }
}

pnpm — pnpm.overrides

json
{
  "pnpm": {
    "overrides": {
      "vulnerable-pkg": "1.2.4",
      "vulnerable-pkg@<2": "2.0.0"
    },
    "peerDependencyRules": {
      "ignoreMissing": ["react-native"],
      "allowedVersions": {
        "react": "18"
      }
    },
    "patchedDependencies": {
      "some-pkg@3.1.0": "patches/some-pkg@3.1.0.patch"
    }
  }
}

Overrides bypass semver. They're a hotfix, not a strategy. Document each override in CHANGELOG.md and revisit when the upstream issue closes.

peerDependencies and peerDependenciesMeta

Peer dependencies say "I need this, but the host app should install it." Classic example: a React component library — the consumer's app already has React, you don't want to bundle a second copy.

json
{
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-dom": ">=18.0.0"
  },
  "peerDependenciesMeta": {
    "react-dom": { "optional": true }
  }
}

peerDependenciesMeta.<name>.optional = true tells the package manager not to warn when the peer is missing — useful when a feature only kicks in if a peer is present.

npm 7+ auto-installs peers (configurable via legacy-peer-deps in .npmrc). pnpm requires explicit peer install by default — its strictness is what catches "missing peer" bugs that npm hides.

optionalDependencies and bundleDependencies

optionalDependencies install on a best-effort basis. If a native build fails (the canonical case: fsevents on Linux), the install continues without it.

json
{
  "optionalDependencies": {
    "fsevents": "^2.3.3"
  }
}

bundleDependencies (alias: bundledDependencies) lists deps that should be bundled inside the tarball published to npm. Useful for shipping a forked or private dep without a registry round-trip.

json
{
  "bundleDependencies": ["my-private-lib"],
  "dependencies": {
    "my-private-lib": "1.0.0"
  }
}

When a consumer npm installs your package, my-private-lib is unpacked from your tarball — npm never queries the registry for it.

funding, private, sideEffects

funding

json
{
  "funding": "https://github.com/sponsors/alicedev"
}

Or an array for multiple sources:

json
{
  "funding": [
    { "type": "github", "url": "https://github.com/sponsors/alicedev" },
    { "type": "opencollective", "url": "https://opencollective.com/myproject" }
  ]
}

npm fund lists every funded dep in your tree:

bash
npm fund

Output:

text
my-app@1.0.0
├── https://github.com/sponsors/alicedev
│   └── lodash@4.17.21
└── https://opencollective.com/postcss
    └── postcss@8.4.0

private

json
{ "private": true }

Prevents npm publish with an explicit error. Always set this on application repos and the root of a monorepo.

sideEffects — tree-shaking hint

Tells bundlers (Webpack, Rollup, esbuild) which files are pure (safe to eliminate if their exports aren't imported). false = entire package is pure.

json
{
  "sideEffects": false
}
json
{
  "sideEffects": ["*.css", "./src/polyfill.js"]
}

A misconfigured sideEffects is a common source of "import works in dev but is undefined in prod" bugs — the bundler legitimately tree-shook the module thinking it had no side effects.

browser field

Bundler hint: redirect Node-only modules to browser equivalents at build time.

json
{
  "main": "./dist/index.js",
  "browser": "./dist/index.browser.js"
}
json
{
  "browser": {
    "./dist/server.js": "./dist/browser.js",
    "fs": false                                   // stub out Node's fs
  }
}

Largely superseded by "exports" with "browser" conditions for new packages.

Validators and tooling

bash
# Validate package.json against the npm schema
npx publint                          # check publish-readiness

# Find duplicate / outdated deps
npx npm-check-updates                # interactive upgrade

# Sort keys to canonical order
npx sort-package-json                # or use prettier-plugin-packagejson

# Spot dependency-confusion / typosquatting
npx better-npm-audit audit

Output (publint):

text
✔ All exports resolve correctly
✔ Types are referenced for every entry
✖ "main" field uses ./dist/index.js but exports prefer ./dist/index.cjs

Common pitfalls

  • type: "module" breaks all .js CommonJS files — switch ambiguous files to .cjs or rewrite to ESM.
  • exports blocks legitimate deep imports — once you add "exports", consumers can no longer reach into dist/internal/foo. List every public path explicitly.
  • bin script has no shebang — Windows shims fall back to node, but POSIX symlinks fail with "exec format error".
  • postinstall runs on consumers too — every npm install <your-pkg> runs your postinstall in their machine. Move build steps to prepare so they only run during your own development.
  • peerDependencies not installed — npm 7+ auto-installs, but old setups (or pnpm with strict mode) won't. Document peers in the README.
  • packageManager field ignored without Corepack enabled — run corepack enable once per machine.
  • Hoisted deps leak undeclared imports — code imports lodash without listing it as a dep, works because some other dep hoisted it. Catch with eslint-plugin-import (import/no-extraneous-dependencies) or pnpm's strict mode.
  • workspaces: ["packages/*"] traverses node_modules — exclude with !packages/**/node_modules or rely on the package manager's default ignore.
  • Lockfile mismatches in CInpm install updates package-lock.json; CI should use npm ci (immutable) to catch drift.
  • Version specifier * resolves to the latest including breaking changes — never publish *; always use ^ or ~.

Real-world recipes

Pin Node + pnpm + lockfile across the team

json
{
  "engines": {
    "node": ">=20.0.0",
    "pnpm": ">=9.0.0"
  },
  "packageManager": "pnpm@9.12.1"
}

Pair with .npmrc:

text
engine-strict=true
package-manager-strict=true

Now any colleague who runs npm install gets a hard error directing them to pnpm.

Single-source the version across multiple packages

In a monorepo, hard-code the version in one place and $version it in others.

json
// packages/core/package.json
{
  "name": "@my/core",
  "version": "1.2.3",
  "dependencies": { "@my/utils": "workspace:*" }
}
json
// packages/cli/package.json
{
  "name": "@my/cli",
  "version": "1.2.3",
  "dependencies": { "@my/core": "workspace:^" }
}

Bump all versions in lockstep with pnpm -r exec pnpm version 1.2.4.

Run pre-commit checks via a prepare script

json
{
  "scripts": {
    "prepare": "husky"
  },
  "devDependencies": { "husky": "^9.0.0" }
}

When teammates npm install, Husky installs the git hook automatically because prepare runs after install.

Dual ESM + CJS publishing

json
{
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  "files": ["dist"]
}

Tools like tsup, unbuild, and Vite's library mode generate both bundles in one build.

Allow GitHub Sponsors to surface in npm fund

json
{
  "funding": {
    "type": "github",
    "url": "https://github.com/sponsors/alicedev"
  }
}

Audit and fix only production dependencies

bash
npm audit --omit=dev
npm audit fix --omit=dev

Output:

text
found 0 vulnerabilities

Strip dev-only fields when publishing

files already does this, but for a finer cut use a publish-time transform:

bash
# Publish a transformed package.json (with @clean-publish)
npx clean-publish

Output:

text
✔ Wrote tarball with stripped scripts.dev, scripts.test, devDependencies

See also

  • npm, pnpm, yarn, bun — package managers that read this file
  • Node runtime, modules — how type, exports, imports are resolved
  • Vite, Vitest — typical scripts and devDependencies companions
  • Bun, Deno — alternative runtimes that also read package.json (Bun fully; Deno selectively)
  • Biome, ESLint, PrettierdevDependencies lint/format ecosystem