pnpm Monorepo Setup
Why this matters
JavaScript monorepos without a clear structure quickly become hard to maintain: internal packages get duplicated, contributors run different pnpm versions, and node_modules resolution surprises you at build time. The workspace:* protocol solves internal package linking — your app's import of @acme/math always resolves to the live source in libs/math, not a stale npm publish. Pinning pnpm and Node in the repo root ensures every contributor and CI runner uses exactly the same toolchain.
Prerequisites: Node 22.18+ (or 24), corepack enable so the pinned pnpm version is enforced.
1. Pin pnpm and Node in the root package.json
Start here so every contributor and CI runner uses the exact same pnpm version without a separate install step. Corepack reads packageManager and enforces it.
{
"name": "acme",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsdown",
"lint": "eslint . --fix",
"format": "prettier . --write",
"type-check": "pnpm -r type-check",
"test": "pnpm -r --if-present test"
},
"packageManager": "pnpm@10.33.4"
}Pin Node too: echo "24" > .nvmrc.
2. Declare the workspace in pnpm-workspace.yaml
The packages: globs tell pnpm which directories are workspace members.
packages:
- apps/*
- libs/*4. Create an internal lib
Internal packages use a scoped name (@acme/*) and reference catalog entries instead of hardcoded versions. The version is 0.0.0 because internal packages are never published from this workspace.
// libs/math/package.json
{
"name": "@acme/math",
"version": "0.0.0",
"type": "module",
"exports": { ".": "./index.ts" },
"dependencies": { "zod": "catalog:" },
"devDependencies": { "typescript": "catalog:" }
}5. Consume the lib from an app
Internal deps use workspace:* — pnpm resolves this to the live source in libs/math, not an npm tarball. Shared third-party deps use catalog: or catalog:<name>.
// apps/web/package.json
{
"name": "@acme/web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": { "dev": "vite", "build": "tsc -b && vite build" },
"dependencies": {
"@acme/math": "workspace:*",
"@tanstack/react-query": "catalog:",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"zod": "catalog:"
},
"devDependencies": {
"@types/react": "catalog:react19",
"@types/react-dom": "catalog:react19",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
}
}6. Install and verify
pnpm install
pnpm ls -r --depth -1 # lists every workspace package pnpm discoveredpnpm install resolves catalog: refs against the catalog block and symlinks @acme/math from libs/math. A missing catalog entry or an unresolved workspace:* target fails install immediately — the error tells you exactly what's missing.
Working with packages day-to-day
# Add a dep to one package
pnpm add --filter @acme/web zod
# Add a dev dep to the root (available workspace-wide)
pnpm add -Dw <pkg>
# Run a script in every package that has it
pnpm -r --if-present test
# Build all packages
pnpm -r run build
# Run only a subset
pnpm --filter "./apps/*" run buildnode-linker: when to switch away from the default
pnpm's default isolated linker uses symlinked node_modules. This is the correct default — it prevents accidental access to undeclared dependencies. Only switch to hoisted if a specific tool (some native loaders, old bundlers) cannot resolve through symlinks:
# .npmrc — only add this if you have a concrete symlink resolution failure
node-linker=hoistedhoisted mimics npm/yarn classic flat layout. Document why you added it — future contributors will wonder.
Variant: single-app frontend (no workspace members)
A standalone frontend that doesn't share code uses pnpm-workspace.yaml only to carry build settings — no packages: key needed:
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- core-js
- esbuildRules to keep
- Internal packages are scoped (
@acme/*) and wired withworkspace:*. Never publish them by accident: keepprivate: trueor omit theversionfield. packageManageris pinned in the rootpackage.json; Node is pinned via.nvmrcand/orengines.node.
See pnpm-catalog.md for how to pin and manage shared third-party versions with pnpm catalogs.