The package.json File
Every Node.js project has one file that ties everything together: package.json. It is a plain JSON document that identifies your project (name, version, description), lists the packages it depends on, and defines the commands used to run, test, and build it. npm reads this file every time you run npm install, npm start, or npm test — and Node itself reads part of it to decide how to parse your files.
If you only ever run npm init -y and never look at package.json again, you will eventually hit confusing bugs: a dependency upgrade that silently breaks your app, a require is not defined error after adding one field, or a build tool that ends up shipped to production. This lesson covers the file completely: every field that matters, how npm resolves version ranges, how scripts actually execute, and the mistakes that trip up almost everyone at least once.
Overview: What package.json Actually Does
package.json is created for you by npm init (or npm init -y for the non-interactive version) in the root of your project. It does two distinct jobs at once:
1. Identity and metadata. The name, version, description, author, license, and keywords fields describe the package. If you never publish to the npm registry, most of these barely matter functionally — but name and version are still used internally (for example, when another tool reads them to print a version banner).
2. Instructions for tooling. The dependencies, devDependencies, scripts, main, exports, type, and engines fields are read by npm and Node to actually do things: install the right packages, run the right commands, resolve the right entry file, and parse your source files correctly.
Semantic Versioning (semver)
Every version in package.json follows MAJOR.MINOR.PATCH (for example 5.1.3). MAJOR increases on breaking changes, MINOR on backward-compatible features, PATCH on backward-compatible bug fixes. Dependency entries do not usually pin an exact version — they specify a range npm is allowed to install:
| Range | Meaning | Example | Allows installing |
|---|---|---|---|
^5.1.3 |
Caret — compatible with, no breaking changes | ^5.1.3 |
5.1.3 up to, but not including, 6.0.0 |
~5.1.3 |
Tilde — patch-level only | ~5.1.3 |
5.1.3 up to, but not including, 5.2.0 |
5.1.3 |
Exact — no upgrades | 5.1.3 |
Only 5.1.3 |
* or latest |
Any version | * |
Whatever is newest at install time |
>=5.0.0 |
Comparator range | >=5.0.0 |
5.0.0 or anything newer |
When you run npm install, npm resolves every range in dependencies and devDependencies (and every transitive dependency’s own ranges) to one specific version per package, then writes those exact versions into package-lock.json. package.json says what is acceptable; package-lock.json records what was actually installed, so a teammate or a CI server running npm ci gets the identical dependency tree you tested with.
Syntax
A package.json is a single JSON object. There is no required order, but these are the fields you will use constantly:
| Field | Purpose |
|---|---|
name |
Package identifier (lowercase, no spaces); required if you ever publish |
version |
Current semver version of your package |
description, keywords, author, license |
Metadata, mostly relevant for published packages |
main |
Entry file loaded when something does require("your-package") |
exports |
Modern, more precise alternative to main for controlling what a package exposes |
type |
"commonjs" (default) or "module" — controls how .js files are parsed |
scripts |
Named shell commands runnable via npm run <name> |
dependencies |
Packages required at runtime, in production |
devDependencies |
Packages only needed for development (test runners, linters, bundlers) |
peerDependencies |
Packages the consumer of your library must provide themselves |
engines |
Node/npm version constraints for the project |
private |
true prevents accidental npm publish |
Examples
Example 1: The Default File From npm init
Running npm init -y in an empty folder generates a minimal file with sensible defaults:
{
"name": "my-app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
This alone is a valid, working package.json. There are no dependencies yet, and the default test script simply fails on purpose, since no tests exist. Everything else in this lesson builds on top of this shape.
Example 2: A Realistic Project File
After installing a couple of packages and adding your own scripts and constraints, a real project’s package.json looks more like this:
npm install express dotenv
npm install --save-dev eslint
{
"name": "task-tracker",
"version": "1.2.0",
"description": "A small REST API for tracking tasks",
"main": "server.js",
"private": true,
"type": "commonjs",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"test": "node --test"
},
"dependencies": {
"express": "^5.0.0",
"dotenv": "^16.4.5"
},
"devDependencies": {
"eslint": "^9.9.0"
}
}
Notice the split: express and dotenv are needed while the app is running, so they go in dependencies. eslint only matters while you are writing code, so it goes in devDependencies. The engines field documents the minimum Node version this project needs, and private: true stops anyone from accidentally publishing an internal API to the public npm registry. The dev script uses Node’s built-in --watch flag (stable since Node 18.11) to restart on file changes, with no extra dependency required.
Example 3: Reading package.json From Your Own Code
Because package.json is just JSON, your own code can read it — a common way to print a version banner or expose a /health endpoint that reports the running version:
const express = require("express");
const { name, version } = require("./package.json");
const app = express();
const port = process.env.PORT ?? 3000;
app.get("/", (req, res) => {
res.json({ service: name, version });
});
app.listen(port, () => {
console.log(`${name} v${version} listening on port ${port}`);
});
Output:
task-tracker v1.2.0 listening on port 3000
In CommonJS, require("./package.json") works directly — Node’s require understands .json files out of the box and parses them for you. This only works because this project’s type field is "commonjs" (the default); the Common Mistakes section below shows what happens when it isn’t, and how to fix it.
How It Works Step by Step
When you run npm install in a project with a package.json:
- npm reads every entry in
dependenciesanddevDependencies. - For each one, it resolves the semver range against the npm registry (or the existing
package-lock.json, if present and still valid) and builds a full dependency graph, including each package’s own dependencies. - It writes the exact resolved version and integrity hash of every package into
package-lock.json. - It downloads and extracts the packages into
node_modules, and places their executable CLIs innode_modules/.bin.
When you then run npm start (shorthand for npm run start):
- npm looks up the
"start"key inscripts. - It temporarily prepends
node_modules/.binto thePATHfor the spawned shell, which is why a script can call a locally installed CLI (likeeslint) by name with no global install. - It spawns the command (here,
node server.js) as a child process, piping its stdout and stderr straight through to your terminal. - Node starts, and before running your first line of code, it walks up from the file being executed looking for the nearest
package.jsonto read thetypefield — this is how it decides whether to parse the file as CommonJS or as an ES module.
Common Mistakes
Mistake 1: Using require() When type Is “module”
Setting "type": "module" in package.json makes Node treat every .js file as an ES module. CommonJS globals like require no longer exist in that scope:
// package.json has "type": "module"
const fs = require("node:fs");
console.log(fs.readFileSync("./package.json", "utf8"));
This throws ReferenceError: require is not defined in ES module scope. The fix is to use import, and to read JSON files with fs instead of relying on require‘s built-in JSON parsing (which is CommonJS-only):
import { readFileSync } from "node:fs";
const pkg = JSON.parse(
readFileSync(new URL("./package.json", import.meta.url), "utf8")
);
console.log(`${pkg.name} v${pkg.version}`);
Mistake 2: Unpinned Dependency Ranges
Leaving a dependency wide open invites unpredictable installs — two developers running npm install on the same day can get different versions, and one of them silently breaks:
{
"dependencies": {
"express": "*"
}
}
Use a caret range so upgrades are limited to backward-compatible releases, and always commit package-lock.json so installs are reproducible regardless of what the range technically allows:
{
"dependencies": {
"express": "^5.0.0"
}
}
Mistake 3: Dev Tools Installed as Regular Dependencies
Running a plain install without --save-dev puts a package in dependencies, which means it is treated as required at runtime:
npm install eslint
A linter has no business shipping to production. Install dev-only tooling with --save-dev (or its short form -D) so it lands in devDependencies, which tools can skip with npm install --omit=dev in production builds:
npm install --save-dev eslint
Best Practices
- Run
npm init -yand hand-edit the result rather than answering every interactive prompt one field at a time. - Always commit
package-lock.json— it is what actually guarantees reproducible installs across machines and CI. - Use caret (
^) ranges for most application dependencies; reserve exact pins for packages where any drift is unacceptable. - Keep build- and test-only tools in
devDependencies, never independencies. - Set
"private": trueon any app that should never be published to the npm registry. - Declare
enginesso teammates and CI get a clear signal when running an unsupported Node version. - Keep
scriptsshort and composable (start,dev,test,lint) instead of one long inline shell command. - For published libraries, prefer the
exportsfield over a baremainto control exactly what consumers can import. - Run
npm auditperiodically, and upgrade dependencies deliberately rather than relying on blindnpm updatein CI.
Practice Exercises
1. In an empty folder, run npm init -y, then hand-edit the generated package.json to add a "start" script that runs node index.js, set "private": true, and add "engines": { "node": ">=20" }.
2. Install express as a normal dependency and eslint as a dev dependency. Open package.json afterward and confirm each landed in the section you expected — explain in one sentence why that split matters for a production deploy.
3. Write a script that reads its own package.json and logs "<name> v<version> starting..." before doing anything else, using require in a CommonJS project. Then change "type" to "module" and fix the script so it still works.
Summary
package.jsonis the manifest npm and Node both read: identity, dependencies, scripts, and module type all live here.- Semver ranges (
^,~, exact) state what versions are acceptable;package-lock.jsonrecords exactly what got installed. - Scripts run via
npm run <name>(withstart/testshorthand), and npm putsnode_modules/.binonPATHwhile they run. - The
typefield decides whether.jsfiles are parsed as CommonJS or ES modules — getting it wrong breaksrequireorimportimmediately. dependenciesvsdevDependenciescontrols what actually ships to production.- Commit the lockfile, pin versions sensibly, mark internal apps
private, and declare supportedengines.
