Skip to content
Chapter 28Lesson 2

pnpm and the lockfile contract

This lesson answers one question: when a teammate clones this repo and runs pnpm install six months from now, do they get the same dependency graph you shipped against? The dependency graph is the full set of packages your app pulls in: the ones you listed, plus every package they depend on, and so on down. A package you never named but that arrives because something you did name depends on it is a transitive dependency. Not “close enough” — bit-for-bit the same, down to every transitive dependency.

The starter from the previous lesson already answers yes, through a small stack of config files you have not opened: .mise.toml, package.json, .npmrc, pnpm-workspace.yaml, and a committed pnpm-lock.yaml. You will not edit any of them here. This is a reading lesson: by the end you will be able to open any web app’s package-manager layer and understand what each file decides.

The starter uses pnpm as its package manager, for three reasons. Its node_modules layout is strict and non-hoisted, so a package can only import what it declared as a dependency: a phantom dependency that npm would silently allow fails at install time instead of crashing in production. It is monorepo-first, so it scales to a workspace of many packages without a change of tools. And it is fully Node-compatible and mature.

Bun is the alternative worth knowing: its cold installs are dramatically faster. But it is roughly 95% Node-compatible, so about one package in twenty surprises you, and it ships no built-in dependency audit. One specific case flips the choice to Bun: a greenfield project where install time dominates CI and the team can absorb the compatibility gaps. This project is not that case, and most are not.

A contributor could end up on the wrong version of pnpm two ways, and the starter closes both. The package-manager version is pinned per repository, not installed once globally and shared across every project: global tooling drifts, and a teammate three minor versions ahead resolves a subtly different dependency graph.

The first pin lives in .mise.toml. You installed mise in the Node setup lesson; it already pins your Node version per project, and pinning pnpm there costs three lines:

.mise.toml
[tools]
node = "24"
pnpm = "11.3.0"

When you cd into the repo, mise reads that file and puts Node 24 and pnpm 11.3.0 on your PATH, with no global install and no manual switching.

The second pin lives in package.json and covers the contributor who does not use mise. This used to be Corepack’s job, but Corepack is leaving Node 25+ distributions. pnpm 11 handles it itself: it ships with manage-package-manager-versions enabled, so once any pnpm is on the machine, the packageManager field is enough.

"packageManager": "pnpm@11.3.0"

On every invocation, pnpm reads that field and, if the installed binary does not match, transparently swaps in the right one. The two pins are belt-and-suspenders: each covers one kind of contributor, and either alone locks the version.

package.json is the manifest at the root of every Node project: it names the project, declares its dependencies, and defines the scripts you run. This one is the smallest manifest a real app needs. Walk it field by field.

{
"name": "chapter-028-themed-product-surface",
"private": true,
"type": "module",
"packageManager": "pnpm@11.3.0",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"format": "biome format --write .",
"lint": "biome lint .",
"check": "biome check --write .",
"verify": "biome ci . && tsc --noEmit && next build",
"test:lesson": "node scripts/test-lesson.mjs",
"preinstall": "npx only-allow pnpm"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "16.2.7",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@tailwindcss/postcss": "^4.3.0",
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
}
}

private: true marks this as an application, not a publishable library. It makes pnpm publish refuse to run, so you can never push the app to the npm registry by accident.

{
"name": "chapter-028-themed-product-surface",
"private": true,
"type": "module",
"packageManager": "pnpm@11.3.0",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"format": "biome format --write .",
"lint": "biome lint .",
"check": "biome check --write .",
"verify": "biome ci . && tsc --noEmit && next build",
"test:lesson": "node scripts/test-lesson.mjs",
"preinstall": "npx only-allow pnpm"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "16.2.7",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@tailwindcss/postcss": "^4.3.0",
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
}
}

type: "module" makes the project ESM-first: every .js and .mjs file is treated as an ES module unless it is explicitly named .cjs.

{
"name": "chapter-028-themed-product-surface",
"private": true,
"type": "module",
"packageManager": "pnpm@11.3.0",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"format": "biome format --write .",
"lint": "biome lint .",
"check": "biome check --write .",
"verify": "biome ci . && tsc --noEmit && next build",
"test:lesson": "node scripts/test-lesson.mjs",
"preinstall": "npx only-allow pnpm"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "16.2.7",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@tailwindcss/postcss": "^4.3.0",
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
}
}

The version lock from the previous section. pnpm reads this on every invocation and swaps in 11.3.0 if the running binary disagrees.

{
"name": "chapter-028-themed-product-surface",
"private": true,
"type": "module",
"packageManager": "pnpm@11.3.0",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"format": "biome format --write .",
"lint": "biome lint .",
"check": "biome check --write .",
"verify": "biome ci . && tsc --noEmit && next build",
"test:lesson": "node scripts/test-lesson.mjs",
"preinstall": "npx only-allow pnpm"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "16.2.7",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@tailwindcss/postcss": "^4.3.0",
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
}
}

Declares the runtime floor: this project requires Node 24 or newer. On its own this is advisory; the .npmrc setting in the next section turns it into a hard error.

{
"name": "chapter-028-themed-product-surface",
"private": true,
"type": "module",
"packageManager": "pnpm@11.3.0",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"format": "biome format --write .",
"lint": "biome lint .",
"check": "biome check --write .",
"verify": "biome ci . && tsc --noEmit && next build",
"test:lesson": "node scripts/test-lesson.mjs",
"preinstall": "npx only-allow pnpm"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "16.2.7",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@tailwindcss/postcss": "^4.3.0",
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
}
}

The scripts you invoke with pnpm <name>. dev/build/start are the Next.js lifecycle you already used; format/lint/check/verify wire up Biome, the focus of a later lesson in this chapter; test:lesson runs the test harness; preinstall is the guard at the end of this lesson.

{
"name": "chapter-028-themed-product-surface",
"private": true,
"type": "module",
"packageManager": "pnpm@11.3.0",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"format": "biome format --write .",
"lint": "biome lint .",
"check": "biome check --write .",
"verify": "biome ci . && tsc --noEmit && next build",
"test:lesson": "node scripts/test-lesson.mjs",
"preinstall": "npx only-allow pnpm"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "16.2.7",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@tailwindcss/postcss": "^4.3.0",
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
}
}

Runs automatically before every install. npx only-allow pnpm aborts the install if the command was not pnpm.

1 / 1

The split between dependencies and devDependencies is easy to get wrong: dependencies are what the app needs to run in production; devDependencies are what you need only to build and develop, such as Biome, TypeScript, and the test runner. A production install can skip the dev set entirely, which keeps deployments lean. When you add a package, you choose which bucket it lands in.

Two more files shape how pnpm behaves here. The first, .npmrc, sets two options:

.npmrc
engine-strict=true
auto-install-peers=true

engine-strict=true gives the engines field its teeth. Without it, declaring node: ">=24" only prints a warning on Node 22, which is easy to ignore until the mismatch resurfaces as a baffling runtime crash in code that relied on a Node 24 feature. With it on, the install stops cold and names the version mismatch, so the error lands before anything is built.

auto-install-peers=true tells pnpm to install peer dependencies automatically. This is already the modern default; the starter sets it explicitly so contributors on an older pnpm version get the same behavior.

The second file, pnpm-workspace.yaml, is a supply-chain defense. From version 10 onward, pnpm refuses to run a dependency’s build scripts — the lifecycle scripts that execute on your machine during install — unless you explicitly allow them. This file is that allowlist:

pnpm-workspace.yaml
onlyBuiltDependencies:
- sharp
allowBuilds:
sharp: true

sharp is the native image library behind Next.js’s image pipeline; it compiles a platform-specific binary during install, so it genuinely needs a build script. The starter allows that one package and nothing else.

These are the pnpm commands you run on a normal workday. You have already run the first; the rest you will use across this chapter’s implementation lessons.

Terminal window
pnpm install # resolve the graph, sync the lockfile, populate node_modules
pnpm add <pkg> # add a runtime dependency (lands in dependencies)
pnpm add -D <pkg> # add a dev dependency (lands in devDependencies)
pnpm remove <pkg> # remove a dependency and re-resolve
pnpm <script> # run a package.json script (shorthand for pnpm run <script>)

pnpm install does three things in one pass: it resolves the full dependency graph from your package.json, writes or verifies the lockfile, and populates node_modules. That last step is where the non-hoisted layout from the “Why pnpm” section pays off. pnpm keeps one global content-addressed store of every package version it has downloaded, and builds node_modules from symlinks into that store rather than fresh copies, so a package version lives on disk once no matter how many projects use it.

pnpm add and pnpm add -D both add a dependency; the -D is the distinction to get right every time. With no flag the package lands in dependencies and ships to production; with -D it lands in devDependencies and does not. Pick the wrong one and you either bloat your production install or fail to install a build tool in CI. pnpm remove is the inverse: it drops a package and re-resolves the graph.

pnpm <script> runs a script from package.json, so pnpm dev is shorthand for pnpm run dev. One sharp edge: if a script name collides with a built-in pnpm command, the built-in wins. A script named install would not run on pnpm install; you would use pnpm run install to disambiguate.

By default pnpm add writes a caret range: pnpm add clsx records "clsx": "^2.1.1", meaning “2.1.1 or any later 2.x”. For ordinary dependencies that is correct, because the caret range plus the lockfile gives you both controlled flexibility and an exact pin. But for a handful of version-sensitive tools you want to drop the caret entirely:

Terminal window
pnpm add -D --save-exact @biomejs/biome

--save-exact pins to the exact version with no range. Reach for it on tooling where a patch bump can silently change behavior: a formatter or linter whose output shifts between patch releases would otherwise rewrite files or fail CI on an install nobody meant to change anything. That is why Biome sits at exactly 2.4.16 in the manifest above, with no caret.

package.json declares your top-level intent — version ranges like ^2.1.1 that mean “anything compatible.” pnpm-lock.yaml records the decision: the resolved graph of every dependency, direct and transitive, each frozen at an exact version with an integrity hash and a resolution path. The manifest says what you would accept; the lockfile says what you got.

You can read both sides side by side. The top of the lockfile lists each direct dependency with the range you asked for and the version pnpm chose; further down, each package gets a full resolution entry. The sha512- integrity hash is a fingerprint of the exact artifact:

"next": "16.2.7",
"clsx": "^2.1.1",

The ranges you declare. next is pinned exact; clsx’s caret accepts any 2.x release. This is what’s allowed, not what’s installed.

Committing this file prevents three concrete bug classes:

  • A teammate runs pnpm install six months later and, because your caret range left room, picks up a patched-but-broken sub-dependency your install never saw. Their build breaks, yours is fine, and nobody can explain why.
  • CI resolves a different graph than your machine from the same package.json, because the ranges allowed it, so a test passes locally and fails on the pipeline.
  • An upstream artifact is tampered with. The integrity hash catches it: the download no longer matches the fingerprint, and the install refuses to proceed.

The lockfile makes all three impossible, but only if you follow three rules.

Commit it. pnpm-lock.yaml belongs in version control, never in .gitignore. In the starter’s .gitignore, node_modules is listed and the lockfile is deliberately not. Skip the commit and you reintroduce every “works on my machine” bug this discipline exists to prevent.

Enforce it in CI with --frozen-lockfile. pnpm install --frozen-lockfile fails the install when the lockfile and package.json disagree, so a contributor who edited package.json without updating the lockfile is caught before merge. The course wires this into CI later.

Never hand-edit it, least of all to resolve a merge conflict. When two branches change dependencies and the lockfile collides, you don’t pick lines from the diff. Resolve the conflict in package.json and run pnpm install; pnpm re-resolves the graph and rewrites the lockfile. Most teams mark the file merge=ours or linguist-generated in .gitattributes so reviewers aren’t tempted to read a diff never meant for human eyes.

One failure mode remains, and the preinstall script in the manifest exists to stop it. A teammate with npm muscle memory runs npm install here out of habit. npm ignores pnpm-lock.yaml, writes its own package-lock.json, and builds a hoisted node_modules that breaks pnpm’s symlink layout, leaving the repo with two conflicting lockfiles.

That is what this line prevents:

"scripts": {
"preinstall": "npx only-allow pnpm"
}

preinstall runs automatically before any install, and only-allow pnpm aborts unless pnpm triggered it. Run npm install against this repo and you are stopped before npm writes a single file:

╭──────────────────────────────────────────────╮
│ │
│ Use "pnpm install" for installation in │
│ this project. │
│ │
╰──────────────────────────────────────────────╯

This is the same move as engine-strict and --frozen-lockfile, and the pattern is the real lesson. Each takes a way to get the dependency graph wrong, wrong runtime, wrong lockfile, wrong package manager, and makes it hard to do by accident rather than trusting everyone to remember the rule. Memory fails; structure does not. That is the philosophy of this layer: reproducible installs the tooling enforces, so “works on my machine” becomes something the repo structurally prevents.

The next lesson reads the AGENTS.md beside these files, which leans on the same commands.