Understanding package.json
Every Node.js project and every published npm package is anchored by a single package.json file at its root. It is the manifest that describes your project’s identity, declares its dependencies, defines the commands you run during development, and tells Node and bundlers how your code should be loaded. Understanding each field turns this file from boilerplate you copy around into a precise control panel for your tooling. This page walks through the fields you will actually edit.
name and version
The name and version fields together form the unique identity of a package. The name must be lowercase, URL-safe, and (if published) unique on the registry; scoped names like @acme/utils group packages under an organization. The version follows semantic versioning — MAJOR.MINOR.PATCH — where a patch bump is a backward-compatible fix, a minor bump adds backward-compatible features, and a major bump introduces breaking changes.
{
"name": "@acme/widgets",
"version": "2.4.1",
"description": "A tiny widget toolkit",
"license": "MIT",
"author": "Ada Lovelace <[email protected]>"
}
If a project is private and should never be published, set "private": true — npm will refuse to publish it, which prevents accidental leaks.
scripts
The scripts field maps short names to shell commands you run with npm run <name>. This is where you centralize your build, test, and dev workflows so collaborators don’t have to memorize long command lines. A handful of names are special: start, test, stop, and restart can be invoked without the run keyword (e.g. npm test).
{
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run",
"lint": "eslint src --max-warnings 0",
"prebuild": "npm run lint"
}
}
npm also runs lifecycle hooks automatically: any script prefixed with pre runs before the matching script, and any prefixed with post runs after. In the example above, npm run build triggers prebuild (the lint check) first, so a failing lint blocks the build.
Pass extra flags to a script by separating them with
--, for examplenpm run test -- --watch. Everything after the--is forwarded to the underlying command rather than consumed by npm.
dependencies vs devDependencies
Dependencies are split by when they are needed. Packages your code imports at runtime go in dependencies; tools only needed while developing — bundlers, test runners, type definitions — go in devDependencies. When someone installs your package as a dependency of theirs, only dependencies are pulled in, keeping their install lean.
{
"dependencies": {
"react": "^18.3.0"
},
"devDependencies": {
"vitest": "^2.0.0",
"eslint": "^9.0.0"
}
}
The leading character in a version range controls how npm updates it:
| Range | Meaning | Example matches |
|---|---|---|
^1.2.3 | Compatible: latest 1.x.x | 1.9.0, not 2.0.0 |
~1.2.3 | Approximate: latest 1.2.x | 1.2.9, not 1.3.0 |
1.2.3 | Exact pin | only 1.2.3 |
* | Any version | anything |
For applications, commit your lockfile (package-lock.json) so every install resolves to the exact same tree regardless of range.
type, main, module, and exports
The type field decides how Node interprets your .js files: "module" treats them as ES modules (import/export), while the default "commonjs" treats them as CommonJS (require). Choosing "module" is the modern default for new projects.
The entry-point fields tell consumers and bundlers which file to load. main is the legacy CommonJS entry, module points to an ESM build for bundlers, and exports is the modern, authoritative map that can define multiple entry points and gate them by environment. When present, exports takes precedence and also encapsulates your package — only the paths you list can be imported.
{
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": "./dist/utils.js"
}
}
With this map, consumers can do import x from "@acme/widgets" or import u from "@acme/widgets/utils", but a deep import like @acme/widgets/dist/secret.js will fail because it isn’t exported.
engines
The engines field declares which Node (or npm) versions your project supports. By default it is advisory, but combined with a tool like Volta or an .npmrc setting of engine-strict=true, it becomes an enforced gate that blocks installs on the wrong runtime.
{
"engines": {
"node": ">=20.0.0"
}
}
A complete example
Putting the pieces together, here is a realistic manifest for a small library:
{
"name": "@acme/widgets",
"version": "2.4.1",
"description": "A tiny widget toolkit",
"type": "module",
"license": "MIT",
"exports": {
".": "./dist/index.js"
},
"scripts": {
"build": "tsup src/index.ts --format esm",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"dependencies": {
"nanoid": "^5.0.0"
},
"devDependencies": {
"tsup": "^8.0.0",
"vitest": "^2.0.0"
},
"engines": {
"node": ">=20.0.0"
}
}
Best Practices
- Set
"private": trueon apps so you never publish them by accident. - Prefer the
exportsmap over baremain/modulefor new packages — it controls entry points and hides internals. - Keep runtime imports in
dependenciesand tooling indevDependencies; mismatches bloat installs or break consumers. - Commit the lockfile and use
npm ciin CI for reproducible, exact installs. - Use
pre/posthooks (andprepublishOnly) to chain validation steps automatically rather than relying on humans to remember them. - Declare
enginesand pair it withengine-strictso contributors fail fast on an unsupported Node version.