The package.json File Explained
Every Node.js project of any size has a package.json at its root. It is the manifest that describes your package to Node, to npm, and to anyone who installs your code: its name and version, how to run it, which module system it uses, and what it depends on. Understanding each field lets you ship libraries, build reproducible apps, and avoid a whole class of resolution and tooling surprises.
Creating a package.json
You rarely write package.json by hand from scratch. The npm init command scaffolds one interactively, and the -y flag accepts all defaults for a quick start.
npm init -y
Output:
Wrote to /home/dev/my-app/package.json:
{
"name": "my-app",
"version": "1.0.0",
"main": "index.js",
"type": "commonjs"
}
Identity fields: name and version
The name and version fields together uniquely identify a release on the npm registry. name must be lowercase, URL-safe, and may be scoped (@acme/utils). version must be a valid semantic version string — MAJOR.MINOR.PATCH. If you never publish, these fields are still required for npm to function, but their values are largely cosmetic.
{
"name": "@acme/data-tools",
"version": "2.4.1"
}
If your package is private and should never be published, add
"private": true. npm will refuse to publish it, protecting you from accidentalnpm publishleaks.
Choosing a module system: type
The type field tells Node how to interpret .js files in your package. The two valid values are "commonjs" (the default when omitted) and "module". With "module", every .js file is treated as an ES module and uses import/export; with "commonjs", files use require/module.exports. You can always override per-file with the .mjs (always ESM) and .cjs (always CommonJS) extensions.
{
"type": "module"
}
Entry points: main, module, and exports
These fields declare what consumers get when they import your package.
| Field | Purpose | Notes |
|---|---|---|
main | Classic entry point | Used by older tooling and CommonJS require |
module | ESM entry hint | A convention honored by bundlers, not by Node itself |
exports | Modern entry map | Takes precedence over main; can gate files and provide conditional ESM/CJS builds |
The exports field is the modern, recommended way to define entry points. It encapsulates your package — anything not listed cannot be imported — and supports conditional resolution so you can ship both ESM and CommonJS builds from one package.
{
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
}
}
Once you add an
exportsmap, deep imports likeimport x from 'pkg/dist/internal.js'stop working unless you list those subpaths explicitly. This is a feature: it lets you keep internals private.
scripts
The scripts field defines named commands you run with npm run <name>. A few names are special and run on lifecycle events — start, test, prepare, and the pre/post prefixes (e.g. prebuild runs before build). Scripts execute with node_modules/.bin on the PATH, so locally installed CLIs are callable by name.
{
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"test": "node --test",
"build": "tsc -p tsconfig.json",
"prebuild": "rimraf dist"
}
}
npm run dev
npm test
Dependency fields
npm distinguishes between several dependency buckets so installs stay lean and predictable.
| Field | Installed when | Use for |
|---|---|---|
dependencies | Always (including by consumers) | Runtime requirements your code imports |
devDependencies | Only in the project itself, not by consumers | Test runners, bundlers, linters, types |
peerDependencies | Not auto-installed; expected in host | Plugins that must share the host’s copy (e.g. a React component lib expecting react) |
optionalDependencies | Attempted, ignored on failure | Platform-specific or nice-to-have packages |
Version ranges use semver operators: ^1.2.3 allows compatible updates (>=1.2.3 <2.0.0), ~1.2.3 allows patch updates only, and an exact 1.2.3 pins precisely.
{
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"typescript": "^5.5.4",
"@types/node": "^22.5.0"
},
"peerDependencies": {
"react": ">=18"
}
}
engines and bin
The engines field declares which Node (or npm) versions your package supports. By default it is advisory — installs still succeed — but you can enforce it with an .npmrc containing engine-strict=true.
{
"engines": {
"node": ">=20.0.0"
}
}
The bin field exposes executable commands. When your package is installed globally or its bin is run via npx, npm creates a symlink on the PATH pointing to the listed file. The target file should start with a shebang (#!/usr/bin/env node).
{
"bin": {
"my-cli": "./bin/cli.js"
}
}
A realistic example
Putting it together, here is a small ESM library that also ships a CLI.
{
"name": "@acme/greet",
"version": "1.0.0",
"description": "Friendly greeting utilities",
"type": "module",
"exports": {
".": "./src/index.js"
},
"bin": {
"greet": "./bin/greet.js"
},
"scripts": {
"test": "node --test",
"lint": "eslint src"
},
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"kleur": "^4.1.5"
},
"devDependencies": {
"eslint": "^9.9.0"
},
"license": "MIT"
}
Best Practices
- Set
"type": "module"for new projects and use.cjsonly where a dependency forces CommonJS. - Prefer the
exportsmap overmainto control your public surface and ship dual ESM/CJS builds. - Add
"private": trueto apps you never intend to publish to avoid accidental publishes. - Keep build, test, and lint tools in
devDependenciesso consumers don’t download them. - Use
peerDependenciesfor plugins so the host application controls the shared version. - Declare
enginesto document supported Node versions, and enableengine-strictin CI if you need it enforced. - Always commit your
package-lock.jsonalongsidepackage.jsonfor reproducible installs.