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 installdownloads from. - package.json
- The file that describes a project: its name, scripts, and the packages it depends on.
- Dependency
- A package your project needs.
dependenciesare needed at run time,devDependenciesonly 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.21that 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.
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.
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.
{
"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.
{
"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.
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.
Try it yourself
- 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
Add a test script
In the second example, add
"pretest": "npm run lint"and"test": "vitest run"to the scripts, then callnpmRun("test"). Predict the four lines before you press Run. - 3
On your own computer
Make a folder, run
npm init -y, thennpm install date-fns. Opennode_modulesandpackage-lock.json. Deletenode_modulesand runnpm ci: it comes back exactly the same.
Code & diagrams
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
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 falseThe `> 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
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"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
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.0Break 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.
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.
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) duringnpm install; supply-chain attacks often hide there. Defences: review new dependencies before adding them, keep the lockfile in code review, runnpm ci --ignore-scriptswhere builds allow it, enablenpm auditand automated update PRs (Dependabot or Renovate), and check package provenance (npm audit signaturesverifies 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 accidentallyimporta 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 withERESOLVE. - ▸
npm installresolves frompackage.jsonranges and the existing lockfile, and may update the lockfile.npm ciis faster and deterministic for clean installs.lockfileVersion: 3(npm 9+) stores only thepackagessection; older npm versions wrote larger, backwards-compatible files.
Remember this
- 1
A package is a folder of reusable code with a
package.jsonfile, 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 -ycreates apackage.jsonfor a new project. Alternatives that use the same registry and the samepackage.jsonare pnpm, Yarn and Bun. - 2
**
package.json** describes your project in JSON:name,version,"type": "module"(so Node.js treats.jsfiles as ES modules, Topic 12.1),scripts,dependenciesanddevDependencies. **dependencies** are packages your code needs when it runs in production (for exampledate-fnsthat formats dates on the page). **devDependencies** are tools only used while developing and building (Vite, ESLint, Prettier, TypeScript, a test runner).npm install date-fnsadds to the first;npm install -D vite(short for--save-dev) adds to the second. - 3
npm installdownloads packages into the **node_modulesfolder, together with their dependencies (called transitive dependencies**), so a project with five direct dependencies can easily have hundreds of folders there. Never editnode_modulesand never commit it to Git: add it to.gitignore. Anyone can recreate it frompackage.jsonand the lockfile with one command. - 4
Versions follow semver (semantic versioning):
MAJOR.MINOR.PATCH, like4.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. Inpackage.jsonyou write a range, not one version.^4.17.21(caret, npm's default) allows anything>=4.17.21and<5.0.0.~4.17.21(tilde) allows only patches:>=4.17.21 <4.18.0.4.17.21alone means exactly that version. Below 1.0.0 the caret is stricter:^0.3.1means>=0.3.1 <0.4.0, because in 0.x versions a minor bump may break things. - 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 anintegrityhash (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 deletesnode_modules, installs exactly what the lockfile says, never changes the lockfile, and fails if the lockfile andpackage.jsondisagree. Usenpm installwhen you are adding or updating packages. - 6
**
scripts** are named commands:"dev": "vite","build": "vite build","lint": "eslint .". Run them withnpm run build(npm testandnpm starthave shortcuts). While a script runs,node_modules/.binis added to thePATH(the list of folders the terminal searches for programs), sovitefinds the locally installed copy. Scripts namedprebuildandpostbuildrun automatically before and afterbuild. **npx <tool>** runs a package's command without adding it toscripts, downloading it temporarily if needed.
Explain it without notes
What is the difference between dependencies and devDependencies?
Explain semver and what ^, ~ and an exact version mean in package.json.
Why should you commit package-lock.json, and when do you use npm ci instead of npm install?
How do npm scripts find tools like vite and eslint?
Practice
Write a function bumpType(oldVersion, newVersion) that returns "major", "minor", "patch" or "same".
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.
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 updateand for libraries that others install.
Done when you can
I can create a
package.json, install packages and telldependenciesfromdevDependencies.I can read semver and explain
^,~and exact versions, including the 0.x rule.I commit
package-lock.json, ignorenode_modules, and usenpm ciin CI.I can write and run npm scripts, including
preandpostscripts, and usenpx.I know the basic supply-chain defences: review, lockfile,
npm audit, careful updates.