Command Palette

Search for a command to run...

Hectal
PHASE 12Intermediate ~30 min· topic 3 of 6

Topic 12.3

npm and package.json

In one line

npm is the package manager that comes with Node.js: package.json lists your project's name, scripts and the packages it depends on (with version ranges), node_modules holds the installed code, and the lockfile pins the exact versions so every install is identical.

Think of it like this

A recipe card and a pantry. Your recipe card (package.json) says "flour, any brand, 1 kg or more" and lists the steps ("bake: 40 minutes at 180"). The shop (the npm registry) has thousands of ingredients. Your pantry (node_modules) holds what you bought. The receipt (package-lock.json) records the exact brand and pack size you bought, so a friend following your card buys the same things, not whatever is on the shelf that day.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Package
A folder of reusable code with a package.json, shared through a registry.
npm registry
The public online store of packages at npmjs.com that npm install downloads from.
package.json
The file that describes a project: its name, scripts, and the packages it depends on.
Dependency
A package your project needs. dependencies are needed at run time, devDependencies only while building and testing.
Transitive dependency
A package that one of your packages depends on, installed automatically even though you never named it.
Semver
Semantic versioning: MAJOR.MINOR.PATCH, where the number that changes tells you how big the change is.
Version range
A rule like ^4.17.21 that allows a set of versions instead of exactly one.
Lockfile
package-lock.json: the exact versions of every installed package, so installs repeat exactly.
CI
Continuous Integration: a server that automatically installs, tests and builds your code on every change.
npx
A command that runs a package's program, downloading it for one use if it isn't installed.

Step by step

01Start a project

In an empty folder, npm init -y writes a package.json with default answers (-y means "yes to everything"). You can edit it by hand afterwards; it is plain JSON.

terminal
$ npm init -y
── expected output ──
Wrote to /home/asha/my-shop/package.json:
 
{
"name": "my-shop",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": ""
}

02Install a run-time dependency and a dev tool

date-fns is used by the code that ships to users, so it's a normal dependency. vite is only used to develop and build, so it goes in devDependencies with -D. Each install updates package.json, package-lock.json and node_modules.

terminal
$ npm install date-fns && npm install -D vite
── expected output ──
added 1 package, and audited 2 packages in 2s
 
found 0 vulnerabilities
 
added 12 packages, and audited 14 packages in 5s
 
5 packages are looking for funding
run `npm fund` for details
 
found 0 vulnerabilities

03Read the result

npm saved caret ranges for the versions it installed. "type": "module" was added by hand so Node.js treats .js files as ES modules. The scripts call the locally installed vite.

package.jsonwhole filejson
{
  "name": "my-shop",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "date-fns": "^4.1.0"
  },
  "devDependencies": {
    "vite": "^7.1.5"
  }
}

04Look inside the lockfile

Each installed package has an entry keyed by its path in node_modules, with the exact version, where it came from and a SHA-512 integrity hash. If a downloaded file doesn't match the hash, npm refuses to install it.

package-lock.json (excerpt)whole filejson
{
  "name": "my-shop",
  "lockfileVersion": 3,
  "packages": {
    "node_modules/date-fns": {
      "version": "4.1.0",
      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
      "integrity": "sha512-Ukq0owbQ...(shortened)...Ez4xaNg==",
      "license": "MIT"
    }
  }
}

05Clone and install exactly

A teammate (or a CI server) clones the repository, which has package.json and package-lock.json but no node_modules. npm ci rebuilds the exact same tree from the lockfile.

terminal
$ npm ci
── expected output ──
added 14 packages, and audited 14 packages in 3s
 
found 0 vulnerabilities
Clone and install exactlydiagram
Rendering diagram…

06Keep packages healthy

npm outdated lists packages with newer versions; npm update moves to the newest versions inside your ranges; a major upgrade is a deliberate npm install vite@latest followed by reading the changelog. npm audit checks your tree against a database of known vulnerabilities and suggests fixed versions.

terminal
$ npm outdated
── expected output ──
Package Current Wanted Latest Location Depended by
vite 7.1.5 7.1.9 8.0.2 node_modules/vite my-shop

Try it yourself

  1. 1

    Test your own ranges

    In the first example, add the case ["^0.0.3", "0.0.4"]. Predict first: with two leading zeros, which number is the "first non-zero" that may not change?

  2. 2

    Add a test script

    In the second example, add "pretest": "npm run lint" and "test": "vitest run" to the scripts, then call npmRun("test"). Predict the four lines before you press Run.

  3. 3

    On your own computer

    Make a folder, run npm init -y, then npm install date-fns. Open node_modules and package-lock.json. Delete node_modules and run npm ci: it comes back exactly the same.

Code & diagrams

How caret, tilde and exact ranges decide New tab

This is a small copy of the rules in the real `semver` package that npm uses. The real one also handles ranges like `>=1.2 <2`, `1.x`, `||`, and pre-release versions such as `2.0.0-beta.1`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

^4.17.21 allows 4.18.0 true
^4.17.21 allows 5.0.0 false
~4.17.21 allows 4.17.30 true
~4.17.21 allows 4.18.0 false
^0.3.1 allows 0.3.9 true
^0.3.1 allows 0.4.0 false
4.17.21 allows 4.17.22 false
Reading package.json and simulating npm run New tab

The `> name@version script` lines copy what npm itself prints before running each script. A real `prebuild` that runs `npm run lint` would also print the lint script's own two lines.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Shipped to users: [ 'date-fns' ]
Only for building: [ 'vite', 'eslint' ]
> my-shop@1.0.0 prebuild
> npm run lint
> my-shop@1.0.0 build
> vite build
> my-shop@1.0.0 postbuild
> node scripts/report-size.js
npm error Missing script: "deploy"
Why the lockfile matters New tab

Same `package.json`, different day, different code. If 1.6.0 had a bug, Friday's build would break while Monday's worked. The lockfile keeps every machine on the version you tested.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Monday, npm install: chart-lib 1.5.0
Friday, no lockfile: chart-lib 1.6.0
Friday, npm ci with lockfile: chart-lib 1.5.0

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Commit a package.json change without the lockfile

Add "date-fns": "^4.1.0" to package.json by hand, commit it without running npm install, and let CI run npm ci.

terminal
$ npm ci
── what you'll see ──
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Missing: date-fns@4.1.0 from lock file

Break #2

Run a tool that is only in a script's PATH

After npm install -D vite, type vite straight into the terminal instead of npm run dev.

terminal
$ vite
── what you'll see ──
bash: vite: command not found

Myth vs fact

Myth

node_modules should be committed so the project works everywhere.

Fact

Commit package.json and package-lock.json; ignore node_modules. npm ci recreates it exactly, and it is often hundreds of MB with files built for one operating system.

Myth

^1.2.3 means version 1.2.3.

Fact

It means any version from 1.2.3 up to, but not including, 2.0.0. Only the lockfile pins an exact version.

Myth

A minor version can never break my app.

Fact

Semver is a promise from the author, not a guarantee. Mistakes happen, which is another reason to commit the lockfile and run tests before updating.

Myth

devDependencies don't matter for security.

Fact

Build tools run on your machine and your CI with full access to your files and secrets. A compromised dev tool is as dangerous as a compromised run-time package.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Packages can run install scripts (preinstall, install, postinstall) during npm install; supply-chain attacks often hide there. Defences: review new dependencies before adding them, keep the lockfile in code review, run npm ci --ignore-scripts where builds allow it, enable npm audit and automated update PRs (Dependabot or Renovate), and check package provenance (npm audit signatures verifies registry signatures and attestations).

  • ▸

    npm installs a mostly flat node_modules: it hoists packages to the top so they can be shared, which lets your code accidentally import a transitive package you never declared (a phantom dependency) that disappears when something else changes. pnpm avoids this with a strict, symlinked layout and a global content-addressed store that saves disk space.

  • ▸

    Package entry points are set in package.json: "main" (old), and "exports", which maps import paths and conditions ("import", "require", "browser", "types") to files and hides everything not listed. Since npm 7, peer dependencies (a plugin saying "I need the host's React, version ^19") are installed automatically, and conflicting ones fail with ERESOLVE.

  • ▸

    npm install resolves from package.json ranges and the existing lockfile, and may update the lockfile. npm ci is faster and deterministic for clean installs. lockfileVersion: 3 (npm 9+) stores only the packages section; older npm versions wrote larger, backwards-compatible files.

Remember this

  1. 1

    A package is a folder of reusable code with a package.json file, published so others can install it. npm (Node Package Manager) is both the command-line tool that installs packages (it comes with Node.js) and the public registry at npmjs.com that stores them. npm init -y creates a package.json for a new project. Alternatives that use the same registry and the same package.json are pnpm, Yarn and Bun.

  2. 2

    **package.json** describes your project in JSON: name, version, "type": "module" (so Node.js treats .js files as ES modules, Topic 12.1), scripts, dependencies and devDependencies. **dependencies** are packages your code needs when it runs in production (for example date-fns that formats dates on the page). **devDependencies** are tools only used while developing and building (Vite, ESLint, Prettier, TypeScript, a test runner). npm install date-fns adds to the first; npm install -D vite (short for --save-dev) adds to the second.

  3. 3

    npm install downloads packages into the **node_modules folder, together with their dependencies (called transitive dependencies**), so a project with five direct dependencies can easily have hundreds of folders there. Never edit node_modules and never commit it to Git: add it to .gitignore. Anyone can recreate it from package.json and the lockfile with one command.

  4. 4

    Versions follow semver (semantic versioning): MAJOR.MINOR.PATCH, like 4.17.21. A patch bump (4.17.22) fixes bugs, a minor bump (4.18.0) adds features without breaking anything, and a major bump (5.0.0) may break existing code. In package.json you write a range, not one version. ^4.17.21 (caret, npm's default) allows anything >=4.17.21 and <5.0.0. ~4.17.21 (tilde) allows only patches: >=4.17.21 <4.18.0. 4.17.21 alone means exactly that version. Below 1.0.0 the caret is stricter: ^0.3.1 means >=0.3.1 <0.4.0, because in 0.x versions a minor bump may break things.

  5. 5

    A range means two installs on different days could get different versions. The lockfile, package-lock.json, written by npm, records the exact version, download URL and an integrity hash (a fingerprint of the file) of every installed package, including transitive ones. Commit it. On servers and in CI (automatic build machines) use **npm ci**: it deletes node_modules, installs exactly what the lockfile says, never changes the lockfile, and fails if the lockfile and package.json disagree. Use npm install when you are adding or updating packages.

  6. 6

    **scripts** are named commands: "dev": "vite", "build": "vite build", "lint": "eslint .". Run them with npm run build (npm test and npm start have shortcuts). While a script runs, node_modules/.bin is added to the PATH (the list of folders the terminal searches for programs), so vite finds the locally installed copy. Scripts named prebuild and postbuild run automatically before and after build. **npx <tool>** runs a package's command without adding it to scripts, downloading it temporarily if needed.

Explain it without notes

01

What is the difference between dependencies and devDependencies?

02

Explain semver and what ^, ~ and an exact version mean in package.json.

03

Why should you commit package-lock.json, and when do you use npm ci instead of npm install?

04

How do npm scripts find tools like vite and eslint?

Practice

01

Write a function bumpType(oldVersion, newVersion) that returns "major", "minor", "patch" or "same".

02

Write the package.json scripts for a project that runs Vite in development, builds with vite build, lints with eslint . and runs the linter automatically before every build.

03

For each change, say which version number you bump: (a) fix a rounding bug; (b) add a new optional currency option; (c) rename the function format to formatPrice.

Trade-offs

  • ↔

    Adding a package saves writing code but adds a long-term dependency: updates, security alerts, bundle size and transitive packages you never chose. Small helpers are often better written yourself.

  • ↔

    Caret ranges bring in fixes automatically on update, but also risk; exact pins are predictable but need active updating. With a committed lockfile, ranges mostly matter for npm update and for libraries that others install.

Done when you can

  • I can create a package.json, install packages and tell dependencies from devDependencies.

  • I can read semver and explain ^, ~ and exact versions, including the 0.x rule.

  • I commit package-lock.json, ignore node_modules, and use npm ci in CI.

  • I can write and run npm scripts, including pre and post scripts, and use npx.

  • I know the basic supply-chain defences: review, lockfile, npm audit, careful updates.