Once a monorepo layout exists, the package manager becomes the product surface
everyone feels. Wrong defaults mean ghost dependencies, duplicated React, and CI
that passes locally only because someone's nested node_modules is lying.
I standardized on pnpm workspaces for frontend monorepos. Not because it is fashionable, because its strictness surfaces problems npm and yarn classic happily hide.
The boring scaffolding
At the root:
yaml# pnpm-workspace.yaml packages: - 'apps/*' - 'packages/*'
And a .npmrc that makes the strictness intentional:
inishamefully-hoist=false auto-install-peers=true strict-peer-dependencies=false public-hoist-pattern[]=*eslint* public-hoist-pattern[]=*prettier*
That mix looks contradictory on purpose. I want app code to fail when it imports something it did not declare. I also want tooling packages (ESLint plugins, Prettier) hoisted enough that flat configs and editor integrations do not fight the content-addressable store.
auto-install-peers=true reduces some peer noise. I still turn
strict-peer-dependencies off for day-to-day DX, then tighten it in CI when the
graph is stable enough that peer errors are real signals instead of ceremony.
Declaring workspace links
Internal packages use the workspace protocol:
json{ "name": "@acme/web", "dependencies": { "@acme/ui": "workspace:*", "react": "^19.0.0", "react-dom": "^19.0.0" } }
json{ "name": "@acme/ui", "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" } }
This is the shape that keeps React singular. The UI package peers React; the app
owns the version. Putting React in dependencies of @acme/ui is how you get
two Reacts and a baffling "Invalid hook call" at 4pm on a Friday.
The traps that actually burned me
Phantom imports
pnpm will not let package A import a transitive dependency of package B unless A declares it. That is annoying the first week and invaluable after. The failure mode under hoisted npm is subtler: it works until someone upgrades B and the transitive package moves or vanishes.
When a package needs zod or clsx, declare it. Do not rely on the neighbor.
Peer dependency fan-out
Monorepos amplify peer warnings. A design system depending on Radix, a form package depending on React Hook Form, and an app on React 19 will surface every mismatch at once. My rule:
- Peers for libraries meant to be composed (UI, hooks that take React).
- Dependencies for private implementation (internal helpers nobody else should resolve).
If a warning is about a peer range you intentionally support, widen the range and document it. If it is about something the app never uses, ask why that package is in the graph at all.
Catalogs vs copy-paste versions
When twelve packages pin "typescript": "^5.7.2", drift is inevitable. pnpm
catalogs (or a root pnpm.overrides / packageExtensions strategy) keep the
toolchain aligned:
yaml# pnpm-workspace.yaml packages: - 'apps/*' - 'packages/*' catalog: typescript: 5.7.2 vite: 6.0.7
json{ "devDependencies": { "typescript": "catalog:", "vite": "catalog:" } }
I reach for catalogs once I feel version spelunking in PR review. Before that, root overrides for critical singletons (React, TypeScript) are enough.
Filters that lie
pnpm --filter @acme/web... build builds the web app and its dependencies.
Forgetting the trailing ... and then wondering why @acme/ui is stale is a
rite of passage. I put the common filter shapes in root scripts so people do not
have to remember the punctuation under pressure.
json{ "scripts": { "build:web": "pnpm --filter @acme/web... build", "dev:web": "pnpm --filter @acme/web dev", "lint:changed": "pnpm --filter \"...[origin/main]\" lint" } }
Reading the lockfile without drowning
I do not ask reviewers to read the entire pnpm-lock.yaml. I do ask that
unexpected dependency churn gets a sentence in the PR: "added date-fns to
@acme/web for relative timestamps" beats a silent 400-line lockfile diff.
For suspicious upgrades I use:
bashpnpm why react pnpm list --depth 1 --filter @acme/web
pnpm why is how I find the package that pulled an ancient scheduler or a
second copy of something that should be singleton.
What I optimize for
At workspace scale, predictability beats cleverness:
- Strict resolution by default.
- Explicit workspace links.
- Singletons for React and TypeScript.
- Scripts that encode the graph filters you actually mean.
pnpm is not free complexity. It is complexity moved to install time, which is where I prefer to pay it, before the browser shows a blank white screen with a cryptic hooks error.
Next up in this arc: stepping back from the toolchain and talking about when raw JavaScript (no compile step) is still the responsible choice for small tools.