package.json is the heart of any Node project. In simple language — it tells Node and npm everything about our project: name, version, what to run, what to install, and how others should import from us.
A real-world example:
{
"name": "khoj",
"version": "1.2.0",
"description": "Personal job scraper",
"type": "module",
"main": "./dist/index.js",
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils.js"
},
"scripts": {
"dev": "node --watch src/index.js",
"test": "node --test",
"build": "tsc"
},
"dependencies": {
"axios": "^1.6.0",
"pg": "^8.11.0"
},
"devDependencies": {
"typescript": "^5.3.0"
},
"engines": {
"node": ">=20"
}
}
name and version
name is how npm and require find our package. Lowercase, no spaces, optionally scoped (@scope/name).
version follows semver — MAJOR.MINOR.PATCH. Increment major for breaking changes, minor for new features, patch for fixes.
type — CJS or ESM?
"type": "module"→ all.jsfiles are ESM"type": "commonjs"(or absent) → all.jsfiles are CJS
This setting controls how Node loads our files. See the CommonJS vs ESM note for details.
main, module, exports — the entry points
These three control what consumers get when they import or require our package.
main— the classic entry. Used by CJSrequire()and as the fallback.module— bundler-only field (Webpack, Rollup). Points to an ESM build. Node ignores this.exports— modern, strict, conditional. Beatsmainif present.
{
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
}
}
With exports, anything not listed is blocked — require("my-pkg/internal/secret") throws. This is intentional; it gives us module encapsulation.
scripts — our project’s commands
Anything in scripts runs via npm run <name> (or yarn <name>, pnpm <name>). A few names are special:
start— runs with justnpm starttest— runs with justnpm testpre<x>/post<x>— auto-run before/after script<x>
{
"scripts": {
"dev": "node --watch src/index.js",
"build": "tsc -p tsconfig.json",
"test": "node --test test/",
"lint": "eslint src/",
"prebuild": "rm -rf dist"
}
}
npm run sets node_modules/.bin on the PATH, so we can invoke locally-installed CLIs like tsc or eslint without a global install.
The three dependency buckets
npm install --production. Example: typescript, eslint, vitest.{
"dependencies": {
"express": "^4.18.0"
},
"devDependencies": {
"@types/express": "^4.17.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"react": ">=18"
}
}
Version range syntax
^1.2.3— compatible (≥1.2.3, <2.0.0). The default withnpm install.~1.2.3— patch-level only (≥1.2.3, <1.3.0).1.2.3— exact.*orlatest— anything (don’t do this).>=1.2.3 <2.0.0— explicit range.
engines — declare runtime requirements
Tells installers what Node version we need. Without engine-strict it’s just a warning, but it shows up in errors and documents intent:
{
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
}
}
Many CI systems and platforms (Vercel, Render) read this to pick the right Node version.
Other useful fields
bin— declare CLI executables.npm install -g my-cliputs these on PATH.files— whitelist of files to publish. Without it, npm uses.npmignoreor includes everything.workspaces— array of paths or globs for monorepo sub-packages.private: true— prevents accidentalnpm publish.sideEffects: false— bundler hint for tree-shaking. Means “imports of this package have no side effects, drop unused exports”.
{
"bin": {
"my-cli": "./cli.js"
},
"files": ["dist/", "README.md"],
"private": true,
"sideEffects": false
}
Generating it
npm init -y makes a minimal one. From there, every npm install <pkg> updates dependencies automatically.