npm & package.json
Learn package.json fields, npm install commands, semver ranges, lockfiles, scripts, npx, pnpm, Yarn, and package security basics.
- 01Read a real package.jsonSay what each field means: the package groups, the scripts, the workspaces, and the files others may import.
- 02Predict which version installsUse version ranges and the lockfile to explain why the same package.json can install different versions on different days.
- 03Run tools safelyPick the right npm command, use npx and npm exec, compare pnpm and Yarn, and check a package before you add it.
Why real projects use npm
Small examples fit in one file. Real projects don't. They use code that other people wrote and shared. They also run tools, such as a code checker, before the code goes live. npm and a file called package.json keep track of all of this.
Three words come up all the time:
- A package is shared code plus a few facts about it, such as its name and version.
- A registry is an online store of packages. You can download any version of any package from it.
- A package manager is a program that downloads packages for your project. It also writes down exactly which versions it chose, and it runs your project's commands.
npm is JavaScript's main package registry. It is also the command you type to use it. package.json is a file that says what your project is called, which commands it can run, and which packages it needs. package-lock.json, called the lockfile, is a file that records the exact version of every package npm installed.
A good kitchen doesn't write "buy some vegetables" on a sticky note. It keeps a recipe card that lists the ingredients. It orders from a supplier's catalog. It keeps the receipt that says exactly what was delivered. And it follows the same checklist every day. npm gives a JavaScript project the same four things.
- In real life: The recipe card
- In JavaScript:
package.jsonlists what the project needs - In real life: The supplier's catalog
- In JavaScript: The registry lists every version of every package
- In real life: The delivery receipt
- In JavaScript: The lockfile records the exact versions you got
- In real life: The kitchen checklist
- In JavaScript: npm scripts run the same build, test, and lint steps every time
This lesson builds on setting up your tools, why modules exist, import and export, module resolution, and CommonJS and ESM.
Packages and package.json
REAL FILESThe command npm init creates a first package.json for you. Every package manager reads the same main fields from it:
- the project's name and version,
- its commands, called scripts,
- the packages it needs, sorted into groups,
- its sub-projects, called workspaces,
- the files other projects may import from it,
- the Node.js version it expects.
{
"name": "@acme/color-tools",
"version": "1.4.2",
"type": "module",
"main": "./dist/index.cjs",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "node --test"
},
"dependencies": {
"color-name": "^1.1.4"
},
"devDependencies": {
"typescript": "^5.9.2"
},
"peerDependencies": {
"react": ">=18"
},
"optionalDependencies": {
"fsevents": "^2.3.3"
},
"engines": {
"node": ">=20"
},
"private": false,
"workspaces": ["packages/*"]
}This is a package.json file, not code to run in the browser. Here is what the first lines mean:
nameandversionsay which package this is."type": "module"tells Node.js to treat the package's.jsfiles as ES modules, the kind that useimportandexport.exportslists the files that other projects are allowed to import. When it is there, newer Node.js versions use it instead ofmain, the older single entry file.
{
"name": "@acme/colors",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./palette": "./dist/palette.js"
}
}This is another package.json, not code to run. The exports field allows two paths:
- The
.key is the package itself,@acme/colors. Code that usesimportgetsindex.js. Code that usesrequiregetsindex.cjs. - The
./palettekey allows@acme/colors/palette.
Any other path, such as @acme/colors/internal, is not listed, so Node.js refuses to import it. The package must also really contain these files, or the import fails.
The table below lists each field. Read a row as: the field, what it means, and where you can see it in this lesson.
| Field | What it means | Where you see it here |
|---|---|---|
name | The package's name in the registry or in this set of projects. | The top folder's package is called cf-platform-ui. The app is called cf-client-ui. |
version | The package's own version number. | Both real files on this page say 0.1.0. |
type | How Node.js reads .js files: module means ES modules. Leaving it out, or commonjs, means CommonJS. | This website leaves it out. Next.js and TypeScript handle the files. |
main / exports | The files other projects may import from this package. | Apps often leave these out. Libraries should set them carefully. |
scripts | Named commands you run with npm run. They can use the programs from installed packages. | The top lint script passes the work to cf-client-ui. The app's dev script runs next dev. |
dependencies | Packages the app imports and uses while it runs. | The app lists next, react, react-dom, Firebase, and CodeMirror packages here. |
devDependencies | Tools used to build, test, check, or type-check the code. | The app lists eslint, typescript, and playwright-core here. |
peerDependencies | Packages the project that installs you must already have, in a version that fits. | Common for React add-ons, so the app doesn't end up with two copies of React. |
optionalDependencies | Packages to install if possible. If one can't install on this computer, the install still finishes. | fsevents is a common example. It only works on macOS. |
engines | The Node.js and npm versions the project expects. | Helps a team and its CI servers use a new enough Node.js. |
private | When true, npm refuses to publish the package to the registry. | Both real files on this page set private to true. |
workspaces | The folders of sub-projects that one top folder manages together. | This website uses packages/* and apps/*. |
An app and a library use different fields most. An app cares most about scripts, its packages, its tools, private, and workspaces. A library is a package that other projects install. It must get type, main, exports, peerDependencies, and engines right, because other projects import it.
{
"name": "cf-platform-ui",
"version": "0.1.0",
"private": true,
"workspaces": [
"packages/*",
"apps/*"
],
"scripts": {
"dev": "npm run dev -w cf-client-ui",
"dev:admin": "npm run dev -w cf-admin-ui",
"build": "npm run build -w cf-client-ui",
"build:admin": "npm run build -w cf-admin-ui",
"start": "npm run start -w cf-client-ui",
"test:articles": "npm run test:articles -w cf-client-ui",
"audit:examples": "npm run audit:examples -w cf-client-ui",
"export:tuto": "npm run export:tuto -w cf-client-ui",
"lint": "npm run lint -w cf-client-ui",
"clean": "rm -rf node_modules apps/*/node_modules apps/*/.next apps/*/out && npm cache clean --force"
}
}This is the real file from the folder that holds this website's code. That folder holds several apps, so the top file mainly passes work on to them:
"private": truestops anyone from publishing it to the registry by mistake.workspacessays the sub-projects live inpackages/*andapps/*.- Each script uses
-wto run a command inside one app, such ascf-client-ui.
{
"name": "cf-client-ui",
"version": "0.1.0",
"private": true,
"description": "completefrontend.com: the learning site, lessons, and Tuto.",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test:articles": "node --experimental-strip-types --test tests/*.test.mjs",
"export:tuto": "node --experimental-strip-types scripts/export-tuto-notes.mjs",
"lint": "next lint",
"audit:examples": "node scripts/audit-examples.mjs"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.4",
"@codemirror/lint": "^6.9.7",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@completefrontend/design-system": "0.1.0",
"@lezer/highlight": "^1.2.4",
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.4.6",
"acorn": "^8.18.0",
"acorn-walk": "^8.3.5",
"codemirror": "^6.0.2",
"firebase": "^12.19.0",
"next": "15.4.6",
"prettier": "^3.9.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"sass": "^1.90.0",
"sucrase": "^3.35.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@mdx-js/loader": "^3.1.0",
"@mdx-js/mdx": "^3.1.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.4.6",
"playwright-core": "^1.63.0",
"typescript": "^5"
}
}This is the real file for the app you are reading right now. Its scripts start, build, and check the site. It splits its packages into two groups:
dependenciesholds packages the site uses while it runs, such asnext,react,react-dom, Firebase, the CodeMirror editor, and the site's own design system.devDependenciesholds tools only the developers use, such as ESLint, TypeScript, the MDX loader, and Playwright.
Installing dependencies
SORT ITA package your project needs is called a dependency, because your project depends on it.
When you add or remove a package, npm changes two files: package.json and the lockfile. In package.json, it puts the package in one of two groups. The group says when the package is needed:
dependencies: packages your app imports and uses while it runs.devDependencies: tools you use while building the app, such as a code checker (a linter), a compiler, or a test runner.
npm init -y
npm install react
npm install -D eslint prettier
npm uninstall left-pad
npm run lint
npm run test -- --watch
npm exec -- eslint src/index.js
npx npm-check-updatesType these in a terminal, in your project's top folder. They don't run in the browser. Line by line:
npm init -ycreates apackage.json. The-yskips the questions and uses the default answers.npm install reactadds React todependencies.npm install -D eslint prettieradds two tools todevDependencies.-Dmeans "dev".npm uninstall left-padremoves a package.npm run lintruns the script namedlint.npm run test -- --watchruns thetestscript and passes it the extra flag--watch.npm exec -- eslint src/index.jsruns the ESLint program directly.npx npm-check-updatesruns a program too.npxis a short way to writenpm exec.
The last four lines run tools. They don't change your package groups. If the program you ask npx for is not installed, npm may offer to download it. Check the name carefully before you say yes.
npm install react
npm install --save-dev eslint
npm ls react
npm uninstall reactAgain, these are terminal commands:
- Line 1 adds React to
dependencies. - Line 2 adds ESLint to
devDependencies.--save-devis the long form of-D. - Line 3,
npm ls react, shows where React sits among the installed packages. - Line 4 removes React from
package.jsonand updates the lockfile. Another package may still need its own copy of React, so a copy can stay on disk.
Only try line 4 in a practice project. A real React app breaks without React.
{
"private": true,
"dependencies": { "react": "19.1.0" },
"devDependencies": { "eslint": "^9.0.0" }
}This is a package.json, not code to run. The app imports React, so React is in dependencies. ESLint only checks the code, so it is in devDependencies. The people who build the app run it, and so do automatic checks.
dependencies because the app imports it. ESLint goes in devDependencies because only the people building the app run it.The rule is about how the package is used. A build tool may still need dev packages while it builds the app. But if a server really imports a tool while it runs, that tool belongs in dependencies.
react, which the app's components use while the app runseslint, used only bynpm run linttypescript, used to check the code's types- A shared React widget that says the app must already have
react fsevents, which helps watch files, but only on macOSprivate: truein this website's package.jsonnext, which runs the app and builds itplaywright-core, used by browser tests
Sort each card. Ask: when is this package needed, and is it a package at all?
npm puts installed packages in a folder called node_modules. When your code says import React from "react", Node.js looks for a react folder in the nearest node_modules. If it isn't there, Node.js tries the node_modules in the parent folder, then the next one up.
To save space, npm often moves shared packages up to the top node_modules. This is called hoisting. Because of it, your code can import a package you never listed, and it still works on your machine. That hidden package is called a phantom dependency. It breaks later, on another computer or with another package manager, where the package isn't there.
npm decides which files are on disk. Module resolution decides which file an import name like "react" points to. The module resolution lesson covers exports, bare names, and import maps in the browser.
Version ranges
PLAYGROUNDEvery package version has three numbers, like 1.4.2. This style is called semver, short for "semantic versioning". The numbers are MAJOR.MINOR.PATCH, and each one has a job:
- MAJOR (the first number) goes up when old code may stop working with the package.
- MINOR (the second number) goes up when the package adds features and old code still works.
- PATCH (the third number) goes up for bug fixes only.
In package.json, you usually don't write one exact version. You write a range: a rule for which versions are OK. For example, this site's app lists ^6.20.3 for a CodeMirror package. The ^ sign is part of the rule. It doesn't install anything by itself.
The table below lists the common rules. Read a row as: the rule's name, how you write it, and which versions it allows.
| Rule | You write | Which versions it allows |
|---|---|---|
| Exact | 1.4.2 | Only 1.4.2. Good for tools that must never change on their own. |
| Caret | ^1.4.2 | 1.4.2 or higher, but lower than 2.0.0. New features and fixes are OK; a new first number is not. |
| Caret below 1.0 | ^0.2.3 | 0.2.3 or higher, but lower than 0.3.0. Before 1.0, npm treats a new second number as risky. |
| Tilde | ~1.4.2 | 1.4.2 or higher, but lower than 1.5.0. Only bug fixes. |
| At least | >=1.5.0 | 1.5.0 or anything higher. Libraries usually add a top limit too. |
| X-range | 1.4.x | Any 1.4 version. 1.x means any version that starts with 1. |
| Hyphen | 1.2 - 1.4 | From 1.2.0 up to the last 1.4 version, both ends included. |
| Star | * | Any normal version. Almost never a good idea for a real app. |
| Prerelease | 2.0.0-beta.1 | A test version. Normal ranges skip it unless the range names that prerelease. |
The two rules you will see most are the caret, ^, and the tilde, ~. This table puts them side by side. Read each row as a question, then the answer for each sign.
| Question | Caret ^ | Tilde ~ |
|---|---|---|
| What does it allow? | New features and fixes, up to the next first number. | Only fixes, up to the next second number. |
| Example | ^1.4.2 allows 1.9.0 but not 2.0.0. | ~1.4.2 allows 1.4.9 but not 1.5.0. |
| What if the first number is 0? | ^0.2.3 stops before 0.3.0, because 0.x versions can change a lot. | ~0.2.3 also stops before 0.3.0. |
| When is it a good choice? | App packages where you welcome new features. | Tools where fixes are fine, but you want to check new features first. |
const releases = ["1.4.2", "1.4.9", "1.5.0", "2.0.0"];
const tildeMatches = releases.filter((version) => version.startsWith("1.4."));
const caretMatches = releases.filter((version) => version.startsWith("1."));
console.log(tildeMatches.join(", "));
console.log(caretMatches.join(", "));Line 2 keeps the versions that start with 1.4., like ~1.4.2 would. Line 4 prints 1.4.2, 1.4.9. Line 3 keeps the versions that start with 1., like ^1.4.2 would. Line 5 prints 1.4.2, 1.4.9, 1.5.0.
~ keeps only new patches, so it stops before 1.5.0; ^ also takes new minor versions and stops before 2.0.0.Checking the start of the text only works here because all four versions are 1.4.2 or higher. Real ranges need real number comparisons, so npm uses its semver package for them.
The function below is a small version of npm's semver package. It handles exact versions, ^, ~, *, and x-ranges like 1.4.x. It skips the rarer cases so you can see the main idea: every rule becomes a lowest allowed version and a first version that is too high. Its answers were checked against npm's real semver package when the lesson was written. The lesson tests check only this small function.
Step through the small satisfies function. It handles exact versions, ^, ~, *, and x-ranges. Each run follows one range.
script
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/); return match && match.slice(1).map(Number);}function compare(left, right) { for (let index = 0; index < 3; index += 1) { if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1; } return 0;}function plan(range) { if (range === "*") return { min: [0, 0, 0], max: [Infinity, 0, 0] }; if (range.startsWith("^")) { const min = parseVersion(range.slice(1)); const max = min[0] > 0 ? [min[0] + 1, 0, 0] : min[1] > 0 ? [0, min[1] + 1, 0] : [0, 0, min[2] + 1]; return { min, max }; } if (range.startsWith("~")) { const min = parseVersion(range.slice(1)); return { min, max: [min[0], min[1] + 1, 0] }; } if (/^\d+\.x$/.test(range)) { const major = Number(range.split(".")[0]); return { min: [major, 0, 0], max: [major + 1, 0, 0] }; } if (/^\d+\.\d+\.x$/.test(range)) { const parts = range.split(".").map((part) => part === "x" ? 0 : Number(part)); return { min: parts, max: [parts[0], parts[1] + 1, 0] }; } const exact = parseVersion(range); return exact && { exact };}function satisfies(version, range) { const versionParts = parseVersion(version); const planned = plan(range); if (!versionParts || !planned) return false; if (planned.exact) return compare(versionParts, planned.exact) === 0; return compare(versionParts, planned.min) >= 0 && compare(versionParts, planned.max) < 0;}console.log(satisfies("0.2.9", "^0.2.3"));console.log(satisfies("0.3.0", "^0.2.3"));satisfies(version, range) functionfunction parseVersion(version) { const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/); return match && match.slice(1).map(Number);}function compare(left, right) { for (let index = 0; index < 3; index += 1) { if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1; } return 0;}function plan(range) { if (range === "*") return { min: [0, 0, 0], max: [Infinity, 0, 0] }; if (range.startsWith("^")) { const min = parseVersion(range.slice(1)); const max = min[0] > 0 ? [min[0] + 1, 0, 0] : min[1] > 0 ? [0, min[1] + 1, 0] : [0, 0, min[2] + 1]; return { min, max }; } if (range.startsWith("~")) { const min = parseVersion(range.slice(1)); return { min, max: [min[0], min[1] + 1, 0] }; } if (/^\d+\.x$/.test(range)) { const major = Number(range.split(".")[0]); return { min: [major, 0, 0], max: [major + 1, 0, 0] }; } if (/^\d+\.\d+\.x$/.test(range)) { const parts = range.split(".").map((part) => part === "x" ? 0 : Number(part)); return { min: parts, max: [parts[0], parts[1] + 1, 0] }; } const exact = parseVersion(range); return exact && { exact };}function satisfies(version, range) { const versionParts = parseVersion(version); const planned = plan(range); if (!versionParts || !planned) return false; if (planned.exact) return compare(versionParts, planned.exact) === 0; return compare(versionParts, planned.min) >= 0 && compare(versionParts, planned.max) < 0;}console.log(satisfies("0.2.5", "^0.2.3"));console.log(satisfies("0.3.0", "^0.2.3"));0.2.2no matchDoes not match. Caret range: 0.2.3 or higher, but below 0.3.0.
0.2.3matchMatches. Caret range: 0.2.3 or higher, but below 0.3.0.
0.2.9matchMatches. Caret range: 0.2.3 or higher, but below 0.3.0.
0.3.0no matchDoes not match. Caret range: 0.2.3 or higher, but below 0.3.0.
1.4.0no matchDoes not match. Caret range: 0.2.3 or higher, but below 0.3.0.
1.4.2no matchDoes not match. Caret range: 0.2.3 or higher, but below 0.3.0.
1.4.9no matchDoes not match. Caret range: 0.2.3 or higher, but below 0.3.0.
1.5.0no matchDoes not match. Caret range: 0.2.3 or higher, but below 0.3.0.
1.9.0no matchDoes not match. Caret range: 0.2.3 or higher, but below 0.3.0.
2.0.0-beta.1no matchDoes not match. Caret range: 0.2.3 or higher, but below 0.3.0.
2.0.0no matchDoes not match. Caret range: 0.2.3 or higher, but below 0.3.0.
Matching versions: 0.2.3, 0.2.9. Caret range: 0.2.3 or higher, but below 0.3.0.
satisfies(version, range) function, not npm's semver package. If the function doesn't handle a range, it says so instead of guessing.Some versions have a label after a dash, like 2.0.0-beta.1. This is a prerelease: a test version before the real release. It counts as lower than 2.0.0. Normal ranges skip prereleases unless the range itself names one.
The registry also has dist-tags such as latest and next. A dist-tag is a name that points at one version. It is not a range.
Lockfiles and repeatable installs
STEP THROUGHA range allows many versions on purpose. That helps when you decide to update. But it causes trouble when a teammate, or an automatic check on a server, gets different versions without anyone asking for them.
The lockfile, package-lock.json, stops that. It records three things for every package:
- the exact version npm picked,
- the packages that package needs in turn,
- a hash: a short fingerprint of the download, so npm can tell if the file was changed.
The step-through below follows one made-up package, tiny-color, with the range ^1.4.0. The registry gets new versions over time. Watch which version each install picks.
A range says which versions are OK. A lockfile records the exact versions npm picked. Watch how they give different answers.
script
function compare(left, right) { const a = left.split(".").map(Number); const b = right.split(".").map(Number); for (let index = 0; index < 3; index += 1) { if (a[index] !== b[index]) return a[index] - b[index]; } return 0;}function satisfies(version, range) { const min = range.slice(1).split(".").map(Number); const max = [min[0] + 1, 0, 0]; return compare(version, min.join(".")) >= 0 && compare(version, max.join(".")) < 0;}function resolveLatest(registry, range, date) { const row = registry.filter((entry) => entry[0] <= date).at(-1); return row[1].filter((version) => satisfies(version, range)).sort(compare).at(-1);} ["2026-01-10", ["1.4.0"]], ["2026-02-20", ["1.4.0", "1.4.1"]], ["2026-05-01", ["1.4.0", "1.4.1", "1.5.0"]], ["2026-08-12", ["1.4.0", "1.4.1", "1.5.0", "1.6.2"]],];const range = "^1.4.0";const firstInstall = resolveLatest(registry, range, "2026-02-20");const freshInstallLater = resolveLatest(registry, range, "2026-08-12");const packageLock = { dependencies: { "tiny-color": { version: firstInstall } } };console.log(firstInstall);console.log(freshInstallLater);console.log(packageLock.dependencies["tiny-color"].version);npm ci installs 1.4.1 again, even though a fresh install in August would pick 1.6.2.Now pick a date and compare a fresh install with npm ci.
function compare(left, right) { const a = left.split(".").map(Number); const b = right.split(".").map(Number); for (let index = 0; index < 3; index += 1) { if (a[index] !== b[index]) return a[index] - b[index]; } return 0;}function satisfies(version, range) { const min = range.slice(1).split(".").map(Number); const max = [min[0] + 1, 0, 0]; return compare(version, min.join(".")) >= 0 && compare(version, max.join(".")) < 0;}function resolveLatest(registry, range, date) { const row = registry.filter((entry) => entry[0] <= date).at(-1); return row[1].filter((version) => satisfies(version, range)).sort(compare).at(-1);}const registry = [ ["2026-01-10", ["1.4.0"]], ["2026-02-20", ["1.4.0", "1.4.1"]], ["2026-05-01", ["1.4.0", "1.4.1", "1.5.0"]], ["2026-08-12", ["1.4.0", "1.4.1", "1.5.0", "1.6.2"]],];const range = "^1.4.0";const firstInstall = resolveLatest(registry, range, "2026-02-20");const freshInstallLater = resolveLatest(registry, range, "2026-08-12");const packageLock = { dependencies: { "tiny-color": { version: firstInstall } } };console.log(firstInstall);console.log(freshInstallLater);console.log(packageLock.dependencies["tiny-color"].version);1.4.0, 1.4.1, 1.5.0, 1.6.21.4.11.6.21.4.1On 2026-08-12, a fresh npm install for ^1.4.0 picks 1.6.2. npm ci still installs the locked 1.4.1.
const manifest = { color: "^1.4.0" };
const locked = { color: "1.3.2" };
console.log(locked.color.startsWith("1.4."));
locked.color = "1.4.1";
console.log(locked.color.startsWith("1.4."));Line 1 is the range from package.json, ^1.4.0. Line 2 is the locked version, 1.3.2. Line 3 prints false, because 1.3.2 is lower than the range allows. Line 4 changes the locked version to 1.4.1, so line 5 prints true.
The startsWith check only works for these two versions. npm uses real version rules and checks every package. If a locked version doesn't fit, npm ci stops with an error. To fix it, run npm install. Don't edit the lockfile by hand.
There are two install commands, and each has its own place:
- Use
npm installwhile you work. It can update the lockfile when you changepackage.json. - Use
npm cion a CI server, short for "continuous integration": a computer that tests every change.npm cineedspackage.jsonand the lockfile to match. It deletesnode_modulesand installs exactly what the lockfile says.
Always save the lockfile in your code history for apps. A library can save one too, for its own tests. Projects that install the library still pick its packages from the library's package.json.
npm install
npm ci
npm ci --omit=devRun each of these on its own, in a terminal:
npm installuses the versions in the lockfile when they still fit. It picks new versions only when the lockfile is missing orpackage.jsonchanged.npm ciinstalls from the lockfile and never changes it.npm ci --omit=devdoes the same but leaves out the dev packages. They are still listed in the lockfile. Build and test the app before you use this, because the tools won't be there.
Scripts, hooks, and npx
COMMANDSA script is a short name for a long command, saved in the scripts field of package.json. Everyone on the team then runs the same command the same way.
While a script runs, npm lets it use the programs from your installed packages. So next, eslint, tsc, and test tools work without being installed on the whole computer. (Technically, npm adds the folder node_modules/.bin to PATH, the list of folders your terminal searches for programs.)
{
"scripts": {
"pretest": "npm run lint",
"test": "node --test",
"posttest": "npm run audit:deps",
"audit:deps": "npm audit --audit-level=high"
}
}A script whose name starts with pre runs just before the script with the rest of the name. A script that starts with post runs just after it. These are called hooks. So npm test here runs pretest, then test, then posttest.
npm test, runs three scripts in order. If one fails, the scripts after it don't run.To pass an extra flag to a script, put it after two dashes: npm run test -- --watch. The -- tells npm "the rest is not for me, pass it on". To run a package's program directly, use npm exec -- eslint src/index.js, or the shorter npx.
npm run lint
npm exec -- eslint src/index.js
npx eslint src/index.jsThese terminal commands use the ESLint installed in your project:
npm run lintonly works ifpackage.jsonhas a script calledlint.npm exec -- eslint src/index.jsruns the ESLint program directly.npx eslint src/index.jsdoes the same thing with a shorter command.
If ESLint is not installed, lines 2 and 3 may ask to download it first. On an automatic server, nobody is there to answer. So add the tool to devDependencies first, and let the lockfile record its version.
Hooks also stop on failure. If pretest fails, test doesn't run. And posttest runs only if test passes.
npm, pnpm, Yarn, and Bun
COMPAREnpm is not the only package manager. pnpm, Yarn, and Bun do the same main jobs:
- read
package.json, - pick a version for each range,
- download the packages,
- put them on disk where Node.js can find them,
- write a lockfile.
They differ in speed, in how strict they are about phantom dependencies, and in how much disk space they use. The table compares them. Read a row as: the tool, how it puts packages on disk, and when teams pick it.
| Tool | How it puts packages on disk | When teams pick it |
|---|---|---|
| npm | Comes with Node.js. Writes package-lock.json. Works with almost everything. | A safe default. Most tutorials use it. |
| pnpm | Keeps one shared copy of each package on your computer and links projects to it. Its node_modules only shows the packages you listed. | Big projects with many sub-projects, and teams that want phantom dependencies to fail early. |
| Yarn Classic | Yarn 1. Uses a normal node_modules folder and writes yarn.lock. | Older projects that already use Yarn 1. |
| Yarn Berry | Yarn 2 and newer. New projects skip node_modules and use a .pnp.cjs file to tell Node.js where each package is (Plug'n'Play). | Teams that want every package listed, and are ready to check that their tools support it. |
| Bun | bun install works with npm packages and writes its own lockfile. Current Bun writes a text file, bun.lock. | Fast installs, or projects that run on Bun, once the team agrees. |
npm ci
pnpm install --frozen-lockfile
yarn install --immutableThese are three choices, one per package manager. Don't run all three in one project. Each one installs from its own lockfile and stops if the lockfile would need to change:
npm ciis for npm.pnpm install --frozen-lockfileis for pnpm.yarn install --immutableis for Yarn 2 and newer, not for Yarn 1.
A project uses one package manager and its lockfile. If you install with a second manager, the two lockfiles will drift apart.
A few details from the current documentation of each tool:
- pnpm keeps one shared copy of each package on your computer. It links each project to those copies. Its
node_modulesonly shows the packages you listed, so most phantom dependencies fail right away. - Yarn 1 is called Yarn Classic, and Yarn 2 and newer is called Yarn Berry. Berry can skip
node_modulescompletely. Instead it writes a file,.pnp.cjs, that tells Node.js where each package is. This is called Plug'n'Play. - Bun installs npm packages quickly and writes a text lockfile called
bun.lock.
Staying safe with packages
SUPPLY CHAINInstalling a package means trusting a stranger's code. That code may run in your build, on your server, in your users' browsers, or even while it installs. So check a package before you run npm install:
- Is the name spelled exactly right?
- Who maintains it, and where is its source code?
- How often does it get new releases, and how big is it?
- Does it run scripts when it installs?
- Do you really need it?
npm view react name version repository
npm audit --audit-level=high
npm explain reactThese run in a terminal:
npm view react name version repositoryshows the package's exact name, newest version, and source code address. Attackers publish packages with names that look almost the same as popular ones. This trick is called typosquatting.npm audit --audit-level=highlists known security problems in your installed packages. It shows every problem, but it only fails when a problem is rated high or worse.npm explain reactshows why React is installed: which package asked for it.
npm audit only knows about problems someone has already reported. A brand-new attack won't show up yet.
npm audit --audit-level=high
npm install --ignore-scripts
npm view react dist-tags versions
npm publish --provenanceThese run in a terminal too:
npm audit --audit-level=highchecks for known problems, as above.npm install --ignore-scriptsstill installs packages, but it doesn't run their install scripts. Packages that need a build step during install may not work.npm view react dist-tags versionslists React's dist-tags and every published version.npm publish --provenanceis for people who publish a package. It attaches a record of where and how the package was built. You don't use it to install.
Be careful with npm audit fix --force. It can jump to a new major version, which may break your app.
{
"scripts": {
"preinstall": "node check.js",
"postinstall": "node build.js"
}
}This is a made-up package.json, not code to run. If you install this package, npm may run check.js and build.js on your computer. So before you trust a new package, look at its files and its install scripts, not only its README.
A package can have preinstall, install, and postinstall scripts. These are called lifecycle scripts. Many honest packages need them, for example to build native code. Attackers use them too. The lockfile makes sure you get the same files every time. It doesn't prove those files are safe.
Provenance is a signed record of where a release was built. It helps you check that a release really came from the package's own source code. You should still review a package and its changes before you update your lockfile.
Common misconceptions
- "It works on my computer, so the package must be listed." Not always. Because of hoisting, you can import a package you never listed. Check
package.json. - "The range in package.json is the version I have." No. The range is a rule. The lockfile records the exact version npm picked.
- "CI never installs devDependencies." Most CI jobs install them, because they need the tools to build and test. Only the settings for the live server decide what gets left out.
- "npx installs a program on my whole computer." No. Today
npxworks likenpm exec. It uses the program from your project, or downloads a missing one into npm's cache folder. Check the exact name before you say yes. On automatic servers, add the tool to your packages first. - "npm audit fixes everything." It helps, but you still need to choose packages carefully, read install scripts, review lockfile changes, and check provenance.
The table compares three things that are easy to mix up. Read a row as a question, then the answer for each file or folder.
| Question | package.json | package-lock.json | node_modules |
|---|---|---|---|
| What is it? | The rules you wrote: which packages and which ranges. | The exact versions npm picked. | The installed files on disk. |
| Should I edit it by hand? | Sometimes, carefully. | No. Let npm update it. | No. Install again instead. |
| Does it prove a package is listed? | Yes. This is the one place that counts. | It only shows what npm picked. | No. Hoisting can fool you. |
Practice exercises
5 EXERCISESType the versions from the list that match ^0.2.3.
const matches = ["0.2.3", "0.2.9", "0.3.0"]
.filter((version) => version.startsWith("0.2."));
console.log(matches.join(", "));The matching versions are 0.2.3 and 0.2.9. 0.3.0 is the first version that is too high.
Write the command that runs the test script and passes it --watch.
const scripts = {
test: "node --test",
"test:watch": "npm run test -- --watch"
};
console.log(scripts["test:watch"]);The command is npm run test -- --watch. The -- on its own is for npm. The flag after it, --watch, goes to the test script.
The app's code imports a package, but it was put in devDependencies. Which group should it move to?
const packageJson = {
dependencies: { react: "19.1.0" },
devDependencies: { "@acme/runtime-client": "^2.0.0" }
};
console.log(Object.hasOwn(packageJson.devDependencies, "@acme/runtime-client"));const packageJson = {
dependencies: { react: "19.1.0", "@acme/runtime-client": "^2.0.0" },
devDependencies: {}
};
console.log(Object.hasOwn(packageJson.dependencies, "@acme/runtime-client"));Move @acme/runtime-client to dependencies, because the app's code imports it and it ships with the app.
Type exactly what the code prints.
const firstInstall = "1.4.1";
const freshInstallLater = "1.6.2";
const npmCi = firstInstall;
console.log([firstInstall, freshInstallLater, npmCi].join(" -> "));The three values are: the first install, 1.4.1; a fresh install later, 1.6.2; and npm ci from the lockfile, 1.4.1.
From the top folder of this website's code, which command runs the app's lint script?
The command is npm run lint. That script runs npm run lint -w cf-client-ui, which runs the app's own lint script.
Check your understanding
8 QUESTIONSQuestion 1 of 8What is npm, in one sentence?
Choose an answer to see the explanation.
Question 2 of 8Which field stops this website's code from being published by mistake?
Choose an answer to see the explanation.
Question 3 of 8Which versions from this list does
^0.2.3allow?Read the code, then predictconst matches = ["0.2.3", "0.2.9", "0.3.0"] .filter((version) => version.startsWith("0.2.")); console.log(matches.join(", "));Choose an answer to see the explanation.
Question 4 of 8This object looks like a lockfile. What does the code print?
Read the code, then predictconst lockfile = { dependencies: { "tiny-color": { version: "1.4.1" } } }; console.log(lockfile.dependencies["tiny-color"].version);Choose an answer to see the explanation.
Question 5 of 8A command-line tool is used only by
npm run lint. Which group should it go in?Choose an answer to see the explanation.
Question 6 of 8Both hooks exist. In what order does
npm testrun the scripts?Read the code, then predictconst order = ["pretest", "test", "posttest"]; console.log(order.join(" -> "));Choose an answer to see the explanation.
Question 7 of 8pnpm's stricter node_modules and Yarn's Plug'n'Play both help you find which problem?
Choose an answer to see the explanation.
Question 8 of 8What is the safest habit before you install a new package?
Choose an answer to see the explanation.
Key takeaways
package.jsonholds the project's name, its scripts, the files others may import, and the packages it needs.- Put packages the app imports in
dependencies, and tools indevDependencies. - A range is a rule for which versions are OK. The lockfile records the exact versions npm picked.
- Use
npm installwhile you work, andnpm cion CI servers. - pnpm, Yarn, and Bun install packages in different ways. You still read
package.jsonand check packages the same way.
Remember the one-liner.
package.json says which packages and versions you allow; the lockfile says exactly which ones you got.
Up next: bundlers. A bundler turns your files and installed packages into files a browser can load.