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:

  1. npm reads every entry in dependencies and devDependencies.
  2. 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.
  3. It writes the exact resolved version and integrity hash of every package into package-lock.json.
  4. It downloads and extracts the packages into node_modules, and places their executable CLIs in node_modules/.bin.

When you then run npm start (shorthand for npm run start):

  1. npm looks up the "start" key in scripts.
  2. It temporarily prepends node_modules/.bin to the PATH for the spawned shell, which is why a script can call a locally installed CLI (like eslint) by name with no global install.
  3. It spawns the command (here, node server.js) as a child process, piping its stdout and stderr straight through to your terminal.
  4. Node starts, and before running your first line of code, it walks up from the file being executed looking for the nearest package.json to read the type field — 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 -y and 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 in dependencies.
  • Set "private": true on any app that should never be published to the npm registry.
  • Declare engines so teammates and CI get a clear signal when running an unsupported Node version.
  • Keep scripts short and composable (start, dev, test, lint) instead of one long inline shell command.
  • For published libraries, prefer the exports field over a bare main to control exactly what consumers can import.
  • Run npm audit periodically, and upgrade dependencies deliberately rather than relying on blind npm update in 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.json is 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.json records exactly what got installed.
  • Scripts run via npm run <name> (with start/test shorthand), and npm puts node_modules/.bin on PATH while they run.
  • The type field decides whether .js files are parsed as CommonJS or ES modules — getting it wrong breaks require or import immediately.
  • dependencies vs devDependencies controls what actually ships to production.
  • Commit the lockfile, pin versions sensibly, mark internal apps private, and declare supported engines.