pnpm and the lockfile contract
Here is the question this lesson answers, and it is the question that separates a codebase that ages well from one that rots: when a teammate clones this repo and runs pnpm install six months from now, do they get the same dependency graph you shipped against? Not “close enough” — bit-for-bit the same, down to every transitive dependency.
The starter you booted in the previous lesson already answers yes. It does so through a small stack of config files you have not looked at yet — .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 2026 SaaS project and read its package-manager layer the way an experienced engineer does, because this is the from-scratch project that lays the foundation every later project chapter carries forward. The decisions in these files are worth understanding once, properly.
Why pnpm
Section titled “Why pnpm”The starter uses pnpm as its package manager, and on this stack in 2026 that is the default you reach for without a second thought. Three reasons carry it: its node_modules layout is strict and non-hoisted, so a package can only import what it actually declared as a dependency — a “phantom dependency” that npm would let you get away with surfaces at install time on pnpm, not as a mysterious production crash; it was designed monorepo-first, so it scales to a workspace of many packages without changing tools; and it is a mature, fully Node-compatible ecosystem with no rough edges.
Bun is the alternative worth knowing about — it has dramatically faster cold installs — but it is roughly 95% Node-compatible, which means about one package in twenty surprises you, and it ships no built-in dependency audit. It is ready for some teams in 2026, and the trigger that would flip the choice is specific: a greenfield project where install time genuinely dominates CI and the team has the bandwidth to absorb the compatibility edges. That is not this project, and it is not most projects. pnpm it is.
Pinning pnpm — mise and the post-Corepack reality
Section titled “Pinning pnpm — mise and the post-Corepack reality”There are two ways a contributor could end up running the wrong version of pnpm, and the starter closes both. The decision underneath is that the package-manager version is pinned per-repository, not installed once globally and shared across every project on the machine — global tooling drifts, and a teammate three minor versions ahead resolves a subtly different graph.
The first pin lives in .mise.toml. You installed mise back in the Node setup lesson; it is the tool-version manager that already pins your Node version per-project, and pinning pnpm there too costs three lines:
[tools]node = "24"pnpm = "11.3.0"When you cd into this repo, mise reads that file and makes Node 24 and pnpm 11.3.0 the versions on your PATH — no global install, no manual switching.
The second pin lives in package.json, and it covers the contributor who does not use mise. For years the mechanism here was Corepack, but Corepack is being removed from Node 25+ distributions and the ecosystem is moving off it. pnpm 11 closes the gap itself: it ships with manage-package-manager-versions enabled by default, which means that once any pnpm is on the machine, the packageManager field is enough. You will see this line when we walk the manifest in a moment:
"packageManager": "pnpm@11.3.0"On every single invocation, pnpm reads that field and, if the installed binary does not match, transparently swaps in the right one. So the two pins are belt-and-suspenders: .mise.toml covers the contributor who has mise, the packageManager field covers the one who does not, and either alone is enough to lock the version.
The package.json, line by line
Section titled “The package.json, line by line”package.json is the manifest at the root of every Node project — the file that names the project, declares its dependencies, and defines the scripts you run. This one is deliberately minimal, 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. The field is a guardrail — it makes pnpm publish refuse to run, so you can never push this 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, the 2026 default. 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 only advisory; it becomes a hard error once paired with the .npmrc setting in the next section.
{ "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 and arrive in the Biome lesson later in this chapter; test:lesson is the test harness; preinstall is the guard we close this lesson on.
{ "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 — the mixed-package-manager guard covered at the end of this lesson.
The split between dependencies and devDependencies matters and 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 — Biome, TypeScript, 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; we will get to how in a moment.
.npmrc and the workspace allowlist
Section titled “.npmrc and the workspace allowlist”Two more files configure how pnpm behaves in this repo. The first, .npmrc, holds two settings the course relies on:
engine-strict=trueauto-install-peers=trueengine-strict=true is the line that gives the engines field its teeth. Without it, declaring node: ">=24" only prints a warning when someone installs on Node 22 — easy to ignore, and the failure then shows up later as a baffling runtime crash in code that relied on a Node 24 feature. With engine-strict on, the install itself stops cold, with a message that names the version mismatch. The error lands at the right moment, 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 that a contributor on an older pnpm version gets the same behavior rather than a surprise.
The second file, pnpm-workspace.yaml, exists for one specific reason. From version 10 onward, pnpm refuses to run the build scripts that some dependencies ship — the lifecycle scripts that execute on your machine during install — unless you explicitly allow them. That is a supply-chain defense, and it is one a malicious package would love you to leave wide open. This file is the allowlist:
onlyBuiltDependencies: - sharpallowBuilds: sharp: truesharp is the native image library Next.js uses for its image pipeline; it has to compile a platform-specific binary during install, which means it genuinely needs to run a build script. The starter allows that one package and nothing else. The point of this file is why it exists — a deliberate, named exception to a default that blocks everything by design.
The four daily commands
Section titled “The four daily commands”These are the pnpm commands a SaaS engineer runs on a normal workday. You have already run the first one; the rest you will use across the implementation lessons in this chapter.
pnpm install # resolve the graph, sync the lockfile, populate node_modulespnpm 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-resolvepnpm <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, it writes or verifies the lockfile, and it 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 ever downloaded, and node_modules is built from symlinks into that store rather than from fresh copies. A package version lives on disk once, no matter how many projects use it.
pnpm add and pnpm add -D add a dependency, and the -D is the distinction worth getting right every time: no flag puts the package in dependencies (it ships to production), -D puts it in devDependencies (it does not). Reach for 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 — pnpm dev is shorthand for pnpm run dev. There is one sharp edge here: if a script name collides with a built-in pnpm command, the built-in wins. A script literally named install would not run on pnpm install; you would reach for pnpm run install to disambiguate. Worth knowing so it never confuses you.
One watch-out belongs here. By default pnpm add writes a caret range — pnpm add clsx records "clsx": "^2.1.1", which means “2.1.1 or any later 2.x”. That is the right default for ordinary dependencies, 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:
pnpm add -D --save-exact @biomejs/biome--save-exact pins to the exact version with no range. You 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 intended to change anything. That is exactly why Biome sits at exactly 2.4.16 in the manifest above, with no caret. For everything else, the caret range plus the lockfile is the correct default — which brings us to the lockfile.
The lockfile as a contract
Section titled “The lockfile as a contract”Here is the distinction the whole package-manager layer is built around. package.json declares your top-level intent — version ranges like ^2.1.1 that say “anything compatible.” pnpm-lock.yaml records the actual decision: the resolved, fully-pinned graph of every dependency including every transitive one, each frozen at an exact version, each with an integrity hash and a resolution path. Intent versus decision. The manifest says what you would accept; the lockfile says what you got.
You can see the two sides next to each other. The top of the lockfile lists each direct dependency with both the range you asked for and the exact version pnpm chose; further down, each package gets a full resolution entry. Notice the integrity hash — that long sha512- string 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 says what is allowed, not what is installed.
clsx: specifier: ^2.1.1 version: 2.1.1
# ... further down, the resolution entry:
clsx@2.1.1:resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}engines: {node: '>=6'}The exact decision. The caret range resolved to 2.1.1, and the integrity hash fingerprints that precise artifact so a tampered or substituted download is rejected.
So what does committing this file actually prevent? Concrete failure shapes, each one a real bug class:
- A teammate runs
pnpm installsix months out and, because your caret range left room, picks up a patched-but-broken sub-dependency your original install never saw. Their build breaks; yours is fine; nobody can explain why. - CI resolves a different graph than your dev machine — same
package.json, different resolution, because the ranges allowed it — and a test passes locally but fails on the pipeline. - An upstream artifact is tampered with, and the integrity hash is the one signal that catches it: the download no longer matches the fingerprint, and the install refuses to proceed.
The lockfile makes all three structurally impossible, but only if you follow three rules, and these are not matters of taste:
Commit it. pnpm-lock.yaml belongs in version control. It is never in .gitignore. Look at the starter’s .gitignore — node_modules is there, the lockfile is conspicuously not. That absence is deliberate. Skipping the commit reintroduces every “works on my machine” bug the rest of this discipline exists to prevent.
Enforce it in CI with --frozen-lockfile. Running pnpm install --frozen-lockfile makes the install fail if the lockfile and package.json disagree, so a contributor who edited package.json but forgot to update the lockfile is caught before merge instead of after. The course wires this into CI in the deployment unit; for now, just know the flag exists and what it guarantees.
Never hand-edit it, least of all to resolve a merge conflict. When two branches both changed dependencies and the lockfile collides, you do not pick lines out of the diff. You resolve the conflict in package.json, then run pnpm install — pnpm re-resolves the graph and rewrites the lockfile correctly. Most teams mark the file merge=ours or linguist-generated in .gitattributes so reviewers are not tempted to read a diff that was never meant for human eyes.
The mixed-package-manager guard
Section titled “The mixed-package-manager guard”One failure mode remains, and it is the one the preinstall script you saw in the manifest exists to stop. Picture a teammate who has npm muscle memory and, out of habit, runs npm install in this repo. npm does not know about pnpm-lock.yaml; it generates its own package-lock.json, builds a hoisted node_modules that breaks pnpm’s symlink layout, and leaves the repo with two conflicting lockfiles — two sources of truth, neither trustworthy.
That is what this line prevents:
"scripts": { "preinstall": "npx only-allow pnpm"}preinstall runs automatically before any install, and only-allow pnpm aborts immediately unless the command that triggered it was pnpm. Try npm install against this repo and you get stopped with a message like this before npm can write a single file:
╭──────────────────────────────────────────────╮│ ││ Use "pnpm install" for installation in ││ this project. ││ │╰──────────────────────────────────────────────╯Recognize that box when you see it in the wild — it means the project has chosen pnpm and the guard is doing its job.
This is the same family of move as engine-strict and --frozen-lockfile, and noticing the pattern is the real lesson. Each one 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 every contributor to remember the rule every time. Memory fails; structure does not. That is the whole philosophy of this layer: reproducible installs by default, enforced by the tooling so that “works on my machine” stops being a bug class and becomes something the repo structurally prevents.
You can now read the package-manager layer of any 2026 SaaS project — the version pins, the manifest, the .npmrc and workspace settings, the four daily commands, and the committed lockfile that ties it all into a contract. In the next lesson you will read the AGENTS.md that sits beside these files and leans on the same commands.
External resources
Section titled “External resources”The official case for pnpm: content-addressed store, install speed, and the strict non-flat node_modules.
How the symlink-plus-hardlink layout works, and why a package can only import what it declared.
The install command in full, including the CI flag that fails when the lockfile and manifest disagree.
The version manager behind the per-repo pnpm and Node pins in .mise.toml.