Quick answer:
package.jsonis the manifest of a Node.js project. The fields you will actually touch arenameandversion(identity),scripts(commands run withnpm run),dependenciesanddevDependencies(what to install, with semver ranges like^1.2.0),type(CommonJS or ES modules),main/exports(entry points), andprivate(block accidental publishing). It must be strict JSON: no comments, no trailing commas.
You cloned a repo, ran npm install, and got npm ERR! code EJSONPARSE. Someone left a trailing comma in package.json. Or you published a library and a user reports that import fails while require works. Or CI runs a different version of a dependency than your laptop. Every one of these is a package.json field doing something you did not expect. Here is each field that matters, what it controls, and how people get it wrong.
Inspect a sample configuration in the JSON Viewer and use JSON Compare to review the values that changed between versions.
A Realistic package.json
{
"name": "@acme/invoice-service",
"version": "2.4.1",
"private": true,
"type": "module",
"description": "Generates and emails invoices",
"engines": { "node": ">=20" },
"scripts": {
"dev": "node --watch src/server.js",
"build": "tsc -p tsconfig.json",
"prebuild": "rimraf dist",
"test": "vitest run",
"lint": "eslint .",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^4.19.2",
"pino": "^9.0.0",
"zod": "~3.23.0"
},
"devDependencies": {
"eslint": "^9.0.0",
"rimraf": "^5.0.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0"
}
}
name and version: Identity
name must be lowercase, URL-safe, and at most 214 characters. A scope such as @acme/ namespaces it under an npm organization; scoped packages are private on the registry by default and need --access public to publish openly.
version must be valid semver: MAJOR.MINOR.PATCH, optionally with a pre-release suffix like 3.0.0-beta.2. Do not edit it by hand; npm version patch (or minor, major) bumps it, commits, and creates a git tag in one step. For applications that are never published the version is mostly decorative, but keep it valid or npm complains.
scripts: Your Project's Command Line
Any key under scripts runs with npm run <key>. Four names are special enough to drop the run: npm start, npm test, npm stop, and npm restart. If start is missing, npm start falls back to node server.js.
Scripts run with node_modules/.bin on the PATH, which is why "build": "tsc" works without npx or a path prefix.
pre and post hooks
For any script X, npm automatically runs preX before it and postX after it. In the example above, npm run build executes prebuild (clean dist), then build. The same applies to pretest, postinstall, and prepublishOnly, which is the standard place to build before publishing. Hooks are convenient and also a classic source of confusion when a colleague cannot work out why npm test runs the linter first.
One caution: postinstall runs on every consumer's machine when they install your package. Keep it out of libraries unless it is genuinely required.
dependencies, devDependencies, peerDependencies
| Field | Purpose | Installed for consumers? | Examples |
|---|---|---|---|
dependencies | Code your package needs at runtime | Yes | express, zod |
devDependencies | Build, test, lint tooling | No | typescript, vitest, eslint |
peerDependencies | Must be provided by the consumer, once | No (consumer installs) | react for a React component library |
optionalDependencies | Nice to have; install failure is ignored | If it builds | fsevents |
The practical rule: if deleting the package would break npm start in production, it is a dependency. If it would only break npm run build or npm test, it is a devDependency. npm install --omit=dev (older: --production) installs only the first group, which is what a Docker runtime image should do.
peerDependencies solve the one-copy problem. A React component library must use the same React instance as the app that renders it, so it declares "react": ">=18" as a peer and lets the app supply it. npm 7 and later install missing peers automatically, and fail on conflicts unless you pass --legacy-peer-deps, which is a flag to understand rather than reach for by reflex.
Add packages with the CLI rather than editing the file: npm install express and npm install -D vitest write the correct field, the correct range, and update the lock file in one go.
Semver Ranges: ^ vs ~ vs Exact
| Range | Meaning | Allows |
|---|---|---|
1.2.3 | Exactly 1.2.3 | 1.2.3 only |
^1.2.3 | Same major, at least 1.2.3 | >=1.2.3 <2.0.0 |
~1.2.3 | Same minor, at least 1.2.3 | >=1.2.3 <1.3.0 |
^0.2.3 | 0.x: caret acts like tilde | >=0.2.3 <0.3.0 |
>=1.2.3 <2 | Explicit range | as written |
1.x or 1 | Any 1.something | >=1.0.0 <2.0.0 |
* | Anything (avoid) | >=0.0.0 |
npm:name@1.0.0 | Alias another package | installs name as this key |
file:../lib | Local path | symlink |
github:user/repo#tag | Git source | clones at ref |
npm install defaults to the caret, which trusts that maintainers follow semver and do not break things in minor releases. They usually do; sometimes they do not. The lock file is what protects you in the meantime. The 0.x row is the one that surprises people: before 1.0.0, semver allows minor versions to break, so ^0.2.3 will not pull 0.3.0.
package-lock.json: Why It Exists
A range like ^4.19.2 can resolve to a different version next week. The lock file freezes the exact version, resolved URL, and integrity hash of every package in the tree, including the hundreds you never typed. npm ci installs from the lock file strictly and fails if it disagrees with package.json; that is the command to use in CI and Docker builds. npm install may update the lock file. Commit the lock file, and do not hand-edit it. It is valid JSON, so you can inspect it in any viewer, but at thousands of lines it is a better fit for the compare two JSON files workflow than for reading top to bottom.
engines: Declaring the Node Version
"engines": { "node": ">=20.0.0", "npm": ">=10" }
By default npm only warns when the running Node does not satisfy engines. Add engine-strict=true to a project .npmrc to turn the warning into an install failure. Yarn and pnpm are strict by default. Pair it with a .nvmrc or .node-version file so the version is easy to switch to.
type: CommonJS or ES Modules
"type": "module" makes Node treat every .js file in the package as an ES module: import/export work, require and __dirname do not. Leave it out (or set "commonjs") and .js is CommonJS. The .mjs and .cjs extensions override the setting per file. Flipping this field on an existing project is the single most common cause of ReferenceError: require is not defined. The reading JSON files in Node.js guide shows how the choice changes the way you load a JSON file.
main, exports, and types: Entry Points
main is the legacy entry point: the file loaded by require("your-pkg"). exports is the modern replacement, and it does two things main cannot: serve different files to import and require, and hide everything not listed.
{
"name": "@acme/money",
"main": "./dist/index.cjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./currencies": "./dist/currencies.js",
"./package.json": "./package.json"
}
}
Once exports exists, import "@acme/money/dist/internal.js" throws ERR_PACKAGE_PATH_NOT_EXPORTED, which is the point: consumers can only reach the surface you chose. Keep main for older bundlers. types (TypeScript) tells the compiler where the declarations are; inside exports, the types condition must come first, because conditions are matched in order.
files, private, and the Publishing Fields
files: an allowlist of what goes into the published tarball, e.g.["dist", "README.md"].package.json, the README, and the LICENSE are always included. Without it, everything not in.npmignoreor.gitignoreships, including your tests and sometimes your.env. Runnpm pack --dry-runto see the list before publishing.private: true: makesnpm publishrefuse. Set it on every application and monorepo root. It costs nothing and prevents a very public mistake.bin: maps command names to scripts sonpx your-toolworks.license,repository,description,keywords: metadata shown on the npm page.licenseshould be an SPDX identifier such as"MIT".packageManager: e.g."pnpm@9.1.0", used by Corepack to pin the package manager version for the whole team.
workspaces: Monorepos
{
"name": "acme-monorepo",
"private": true,
"workspaces": ["packages/*", "apps/*"]
}
Each matching folder has its own package.json. A single npm install at the root installs everything, hoists shared dependencies, and symlinks local packages so apps/web can depend on @acme/money without publishing it. Run a script in one workspace with npm run build -w packages/money, or in all of them with --workspaces.
Keep It Valid JSON
package.json is parsed with a strict JSON parser. It is not JavaScript, not JSON5, and there is no comment syntax. This is the error you get for a trailing comma:
npm ERR! code EJSONPARSE
npm ERR! path /home/priya/invoice-service/package.json
npm ERR! JSON.parse Unexpected token } in JSON at position 412 while parsing near '...vitest run",\n },\n "depen...'
npm ERR! JSON.parse Failed to parse JSON data.
npm ERR! JSON.parse Note: package.json must be actual JSON, not just JavaScript.
The "position 412" is a character offset, which is not much help in a 90-line file. Paste the file into JSON Prettifier for the line and column, or read the trailing comma guide for the fix in every editor. If you really need to annotate something, the convention is a key that npm ignores:
{
"//": "Pinned: 3.23.x has a breaking change in the schema API",
"dependencies": { "zod": "~3.23.0" }
}
The full story on why JSON has no comments and what the alternatives are is in comments in JSON.
Key order and sorting
Key order does not matter to npm, but a convention makes diffs readable: name, version, description, then scripts, then dependencies last. npm itself alphabetizes the entries inside dependencies and devDependencies every time it writes the file, so if you add one by hand, keep it sorted or the next npm install will move it and produce a noisy diff. The sort-package-json package enforces the full convention; for a quick review of a dependency block, the Sort Keys option in a JSON formatter does the same thing in a paste.
Common Mistakes
When JSON imports break after a module-mode or build change, follow the JSON import attribute deployment checklist. It distinguishes compiler acceptance from the syntax, asset paths, and exports the production runtime actually loads.
- Build tools in
dependencies. Your production image installs TypeScript, ESLint, and a test runner it never uses. Move them todevDependencies. - Runtime packages in
devDependencies. Works locally, thenCannot find module 'express'in production because the deploy used--omit=dev. - Editing the lock file by hand, or deleting it to "fix" an install. Deleting it silently upgrades every transitive dependency to the newest allowed version. Fix the conflict instead.
- Using
*orlatestas a range. Every install can pull a new major. Use the caret and let the lock file pin. - Forgetting
private: trueon an app. One mistyped command and your internal service is on the public registry. - Setting
type: modulewithout checking the codebase. Everyrequireand__dirnamebreaks at once. Migrate deliberately, or use.mjsfor new files. - Publishing without
filesorexports. Consumers import from your internal paths, and now you cannot refactor without breaking them.