npm and package.json

Read a package.json properly, understand what a version range promises, and keep installs reproducible.

03of 18
≈ 12Min read
6Parts

package.json

  • dependencies — needed at runtime, installed in production.
  • devDependencies — needed only to build or test. npm ci --omit=dev skips them.
  • scripts — npm run dev. start and test may drop the run.
  • engines — a stated requirement; add it so a wrong Node version fails loudly.
the fields that matter
{
  "name": "doors-api",
  "version": "1.4.2",
  "type": "module",
  "private": true,              // stops an accidental publish
  "engines": { "node": ">=20" },
  "scripts": {
    "dev": "node --watch src/app.js",
    "start": "node src/app.js",
    "test": "node --test",
    "lint": "eslint ."
  },
  "dependencies":    { "express": "^4.19.2" },
  "devDependencies": { "eslint": "^9.9.0" }
}

Semantic versioning

MAJOR.MINOR.PATCH
1.4.2
│ │ └── patch: a bug fix, nothing else changed
│ └──── minor: something new, everything old still works
└────── major: something broke on purpose
RangeMeansAccepts
^1.4.2Compatible: no major change1.4.2 → 1.9.9, not 2.0.0
~1.4.2Patches only1.4.2 → 1.4.9, not 1.5.0
1.4.2Exactly thatonly 1.4.2
>=1.4 <2Written outas it reads
*Anythinggood luck
Zero is special

Below 1.0.0 the API is not stable, so ^0.2.3 allows 0.2.9 but not 0.3.0 — the minor position behaves like a major one. This surprises people once, usually during an outage.

The lockfile is the truth

  • package-lock.json is committed. It records the exact version of every package, including your dependencies’ dependencies.
  • Use npm ci in CI and in deployment: it is faster and it cannot drift.
  • node_modules is never committed. .gitignore it on the first commit.
two commands that are not the same
npm install      # resolves the ranges, may pick newer versions, WRITES the lockfile
npm ci           # installs exactly what the lockfile says, deletes node_modules first

The commands you actually use

day to day
npm install express          # add a dependency
npm install -D vitest        # add a dev dependency
npm install                  # install everything in package.json
npm update                   # move up within the ranges
npm outdated                 # what is behind, and how far
npm uninstall express
npm run dev
npx create-vite@latest       # run a package without installing it
npm audit                    # known vulnerabilities in the tree

Before adding a dependency

Every dependency is code you ship without reading, from someone who can publish a new version tonight. That is a reasonable trade — it is just not a free one.

  • When was it last published? Three years is not always abandoned, but check.
  • How big is it, and what does it drag in? A package with forty dependencies is forty things that can break.
  • Could you write it in twenty lines? Sometimes yes — and then you own twenty lines instead of a supply chain.
  • Is it already in Node? fetch, crypto.randomUUID, structuredClone, a test runner and a .env loader are all built in now.

Scripts worth having

Anyone who clones the repository should be able to run npm install && npm run dev and have it work. If that needs a paragraph of instructions, the scripts are wrong.

conventions other people can guess
{
  "scripts": {
    "dev": "node --watch --env-file=.env src/app.js",
    "start": "node src/app.js",
    "test": "node --test",
    "test:watch": "node --test --watch",
    "lint": "eslint .",
    "format": "prettier --write ."
  }
}

Practice