completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

npm & package.json

Learn package.json fields, npm install commands, semver ranges, lockfiles, scripts, npx, pnpm, Yarn, and package security basics.

By the end, you can
  • 01
    Read a real package.jsonSay what each field means: the package groups, the scripts, the workspaces, and the files others may import.
  • 02
    Predict which version installsUse version ranges and the lockfile to explain why the same package.json can install different versions on different days.
  • 03
    Run 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.
Definition

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.

Real-life analogyA restaurant kitchen

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.json lists 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 FILES

The 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.
A package.json with every field you will meetjson
{
  "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:

  • name and version say which package this is.
  • "type": "module" tells Node.js to treat the package's .js files as ES modules, the kind that use import and export.
  • exports lists the files that other projects are allowed to import. When it is there, newer Node.js versions use it instead of main, the older single entry file.
A package that allows two import pathsjson
{
  "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 uses import gets index.js. Code that uses require gets index.cjs.
  • The ./palette key 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.

package.json fields and what each one does
FieldWhat it meansWhere you see it here
nameThe 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.
versionThe package's own version number.Both real files on this page say 0.1.0.
typeHow 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 / exportsThe files other projects may import from this package.Apps often leave these out. Libraries should set them carefully.
scriptsNamed 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.
dependenciesPackages the app imports and uses while it runs.The app lists next, react, react-dom, Firebase, and CodeMirror packages here.
devDependenciesTools used to build, test, check, or type-check the code.The app lists eslint, typescript, and playwright-core here.
peerDependenciesPackages 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.
optionalDependenciesPackages 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.
enginesThe Node.js and npm versions the project expects.Helps a team and its CI servers use a new enough Node.js.
privateWhen true, npm refuses to publish the package to the registry.Both real files on this page set private to true.
workspacesThe 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.

The package.json at the top of this website's codejson
{
  "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": true stops anyone from publishing it to the registry by mistake.
  • workspaces says the sub-projects live in packages/* and apps/*.
  • Each script uses -w to run a command inside one app, such as cf-client-ui.
apps/client-ui/package.jsonjson
{
  "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:

  • dependencies holds packages the site uses while it runs, such as next, react, react-dom, Firebase, the CodeMirror editor, and the site's own design system.
  • devDependencies holds tools only the developers use, such as ESLint, TypeScript, the MDX loader, and Playwright.

Installing dependencies

SORT IT

A 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.
Common npm commandsbash
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-updates

Type these in a terminal, in your project's top folder. They don't run in the browser. Line by line:

  1. npm init -y creates a package.json. The -y skips the questions and uses the default answers.
  2. npm install react adds React to dependencies.
  3. npm install -D eslint prettier adds two tools to devDependencies. -D means "dev".
  4. npm uninstall left-pad removes a package.
  5. npm run lint runs the script named lint.
  6. npm run test -- --watch runs the test script and passes it the extra flag --watch.
  7. npm exec -- eslint src/index.js runs the ESLint program directly.
  8. npx npm-check-updates runs a program too. npx is a short way to write npm 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.

Add, check, and remove one packagebash
npm install react
npm install --save-dev eslint
npm ls react
npm uninstall react

Again, these are terminal commands:

  1. Line 1 adds React to dependencies.
  2. Line 2 adds ESLint to devDependencies. --save-dev is the long form of -D.
  3. Line 3, npm ls react, shows where React sits among the installed packages.
  4. Line 4 removes React from package.json and 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.

A small app's two package groupsjson
{
  "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.

Left: a cart called app code, under dependencies, holds a react card. Right: a cart called dev tools, under devDependencies, holds an eslint card.dependenciesreactapp codeimported by the appdevDependencieseslintdev toolsrun by developers
React goes in 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.

Which group does it belong in?
  • react, which the app's components use while the app runs
  • eslint, used only by npm run lint
  • typescript, 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 macOS
  • private: true in this website's package.json
  • next, which runs the app and builds it
  • playwright-core, used by browser tests
Try it yourself
0 of 8 correct

Sort each card. Ask: when is this package needed, and is it a package at all?

Choose a category for every card. You can change an answer at any time; Reset clears them 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 and module resolution work together

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

PLAYGROUND

Every 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.

Common npm version ranges
RuleYou writeWhich versions it allows
Exact1.4.2Only 1.4.2. Good for tools that must never change on their own.
Caret^1.4.21.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.30.2.3 or higher, but lower than 0.3.0. Before 1.0, npm treats a new second number as risky.
Tilde~1.4.21.4.2 or higher, but lower than 1.5.0. Only bug fixes.
At least>=1.5.01.5.0 or anything higher. Libraries usually add a top limit too.
X-range1.4.xAny 1.4 version. 1.x means any version that starts with 1.
Hyphen1.2 - 1.4From 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.
Prerelease2.0.0-beta.1A 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.

Caret ^ compared with tilde ~
QuestionCaret ^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.
Which of four releases does each rule keep?Pop out in the code editor (opens in a new tab)JavaScript
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.

Two rows. Tilde 1.4.2 matches 1.4.2 and 1.4.9 and stops before 1.5.0. Caret 1.4.2 matches 1.4.2, 1.4.9, and 1.5.0 and stops before 2.0.0.~1.4.21.4.21.4.9stops before 1.5.0^1.4.21.4.21.4.91.5.0stops before 2.0.0
The releases are 1.4.2, 1.4.9, 1.5.0, and 2.0.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 a version check
Step 0 of 5Ready
Your turn: follow the blue line

Step through the small satisfies function. It handles exact versions, ^, ~, *, and x-ranges. Each run follows one range.

Running in
  1. script
Next: line 1
  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"));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Pick a range to check

Picking a range starts the step-through again, with the real results.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Try your own range
A small satisfies(version, range) functionPop out in the code editor (opens in a new tab)JavaScript
function 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"));
Versions to checkcaret
0.2.2no match

Does not match. Caret range: 0.2.3 or higher, but below 0.3.0.

0.2.3match

Matches. Caret range: 0.2.3 or higher, but below 0.3.0.

0.2.9match

Matches. Caret range: 0.2.3 or higher, but below 0.3.0.

0.3.0no match

Does not match. Caret range: 0.2.3 or higher, but below 0.3.0.

1.4.0no match

Does not match. Caret range: 0.2.3 or higher, but below 0.3.0.

1.4.2no match

Does not match. Caret range: 0.2.3 or higher, but below 0.3.0.

1.4.9no match

Does not match. Caret range: 0.2.3 or higher, but below 0.3.0.

1.5.0no match

Does not match. Caret range: 0.2.3 or higher, but below 0.3.0.

1.9.0no match

Does not match. Caret range: 0.2.3 or higher, but below 0.3.0.

2.0.0-beta.1no match

Does not match. Caret range: 0.2.3 or higher, but below 0.3.0.

2.0.0no match

Does not match. Caret range: 0.2.3 or higher, but below 0.3.0.

Try it yourself

Matching versions: 0.2.3, 0.2.9. Caret range: 0.2.3 or higher, but below 0.3.0.

The list uses the lesson's own 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 THROUGH

A 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.

Step through a first install and a later one
Step 0 of 5Ready
Your turn: follow the blue line

A range says which versions are OK. A lockfile records the exact versions npm picked. Watch how they give different answers.

Running in
  1. script
Next: line 18
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);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
A range card ^1.4.0 from package.json. An arrow, npm install on 2026-02-20, leads to a box called lockfile holding a card 1.4.1. An arrow, npm ci on any later day, leads to an installed card 1.4.1.^1.4.0package.jsonnpm install2026-02-201.4.1lockfilepackage-lock.jsonnpm ciany later day1.4.1installed
The first install picks 1.4.1 and writes it in the lockfile. Later, 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.

Pick a date: fresh install or lockfile?
The code behind the date buttonsPop out in the code editor (opens in a new tab)JavaScript
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);
What each install gets2026-08-12
Versions in the registry1.4.0, 1.4.1, 1.5.0, 1.6.2
Saved in the lockfile1.4.1
Fresh install on this date1.6.2
npm ci, from the lockfile1.4.1
Try it yourself

On 2026-08-12, a fresh npm install for ^1.4.0 picks 1.6.2. npm ci still installs the locked 1.4.1.

A real registry also stores dist-tags, hashes, and the packages each package needs. This small model keeps only what matters here: the range and the lockfile.
A locked version that doesn't fit the rangePop out in the code editor (opens in a new tab)JavaScript
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 install while you work. It can update the lockfile when you change package.json.
  • Use npm ci on a CI server, short for "continuous integration": a computer that tests every change. npm ci needs package.json and the lockfile to match. It deletes node_modules and 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.

Three ways to installbash
npm install
npm ci
npm ci --omit=dev

Run each of these on its own, in a terminal:

  1. npm install uses the versions in the lockfile when they still fit. It picks new versions only when the lockfile is missing or package.json changed.
  2. npm ci installs from the lockfile and never changes it.
  3. npm ci --omit=dev does 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

COMMANDS

A 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 that run before and after a testjson
{
  "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.

The command npm test runs three scripts in order: 1. pretest, which runs npm run lint, then 2. test, which runs node --test, then 3. posttest, which runs npm run audit:deps.npm test1. pretest2. test3. posttestnpm run lintnode --testnpm run audit:deps
One command, 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.

Three ways to run the project's ESLintbash
npm run lint
npm exec -- eslint src/index.js
npx eslint src/index.js

These terminal commands use the ESLint installed in your project:

  1. npm run lint only works if package.json has a script called lint.
  2. npm exec -- eslint src/index.js runs the ESLint program directly.
  3. npx eslint src/index.js does 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

COMPARE

npm is not the only package manager. pnpm, Yarn, and Bun do the same main jobs:

  1. read package.json,
  2. pick a version for each range,
  3. download the packages,
  4. put them on disk where Node.js can find them,
  5. 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.

Four package managers compared
ToolHow it puts packages on diskWhen teams pick it
npmComes with Node.js. Writes package-lock.json. Works with almost everything.A safe default. Most tutorials use it.
pnpmKeeps 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 ClassicYarn 1. Uses a normal node_modules folder and writes yarn.lock.Older projects that already use Yarn 1.
Yarn BerryYarn 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.
Bunbun 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.
Install exactly what the team's lockfile saysbash
npm ci
pnpm install --frozen-lockfile
yarn install --immutable

These 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:

  1. npm ci is for npm.
  2. pnpm install --frozen-lockfile is for pnpm.
  3. yarn install --immutable is 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_modules only 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_modules completely. 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 CHAIN

Installing 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?
Look at a package before you add itbash
npm view react name version repository
npm audit --audit-level=high
npm explain react

These run in a terminal:

  1. npm view react name version repository shows 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.
  2. npm audit --audit-level=high lists known security problems in your installed packages. It shows every problem, but it only fails when a problem is rated high or worse.
  3. npm explain react shows 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 commands that help with securitybash
npm audit --audit-level=high
npm install --ignore-scripts
npm view react dist-tags versions
npm publish --provenance

These run in a terminal too:

  1. npm audit --audit-level=high checks for known problems, as above.
  2. npm install --ignore-scripts still installs packages, but it doesn't run their install scripts. Packages that need a build step during install may not work.
  3. npm view react dist-tags versions lists React's dist-tags and every published version.
  4. npm publish --provenance is 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.

A package that runs scripts while it installsjson
{
  "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.

Install scripts need a careful look

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 npx works like npm 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.

package.json, the lockfile, and node_modules compared
Questionpackage.jsonpackage-lock.jsonnode_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 EXERCISES
Exercise 1 · Warm-upPredict a caret range below 1.0

Type the versions from the list that match ^0.2.3.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const matches = ["0.2.3", "0.2.9", "0.3.0"]
  .filter((version) => version.startsWith("0.2."));
console.log(matches.join(", "));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticePass a watch flag to a script

    Write the command that runs the test script and passes it --watch.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const scripts = {
      test: "node --test",
      "test:watch": "npm run test -- --watch"
    };
    console.log(scripts["test:watch"]);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the package in the wrong group

      The app's code imports a package, but it was put in devDependencies. Which group should it move to?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const packageJson = {
        dependencies: { react: "19.1.0" },
        devDependencies: { "@acme/runtime-client": "^2.0.0" }
      };
      console.log(Object.hasOwn(packageJson.devDependencies, "@acme/runtime-client"));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeRead the lockfile timeline

        Type exactly what the code prints.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const firstInstall = "1.4.1";
        const freshInstallLater = "1.6.2";
        const npmCi = firstInstall;
        console.log([firstInstall, freshInstallLater, npmCi].join(" -> "));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeUse this website's own package.json

          From the top folder of this website's code, which command runs the app's lint script?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            Lesson quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What is npm, in one sentence?

              Choose an answer to see the explanation.

            2. Question 2 of 8Which field stops this website's code from being published by mistake?

              Choose an answer to see the explanation.

            3. Question 3 of 8Which versions from this list does ^0.2.3 allow?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            4. Question 4 of 8This object looks like a lockfile. What does the code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const lockfile = { dependencies: { "tiny-color": { version: "1.4.1" } } };
              console.log(lockfile.dependencies["tiny-color"].version);

              Choose an answer to see the explanation.

            5. 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.

            6. Question 6 of 8Both hooks exist. In what order does npm test run the scripts?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const order = ["pretest", "test", "posttest"];
              console.log(order.join(" -> "));

              Choose an answer to see the explanation.

            7. 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.

            8. Question 8 of 8What is the safest habit before you install a new package?

              Choose an answer to see the explanation.

            Key takeaways

            • package.json holds 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 in devDependencies.
            • A range is a rule for which versions are OK. The lockfile records the exact versions npm picked.
            • Use npm install while you work, and npm ci on CI servers.
            • pnpm, Yarn, and Bun install packages in different ways. You still read package.json and 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.

            CompleteFrontend Clear concepts. Working examples.