pnpm Workspace Monorepo
pnpm's strict resolution catches dependency mistakes that npm and yarn silently allow. That's its biggest feature and its biggest source of confusion. Most "weird CI failure" stories in pnpm monorepos boil down to a missing peer-dep declaration that npm-style hoisting was hiding.
When to use
- Standing up a new monorepo or migrating from npm/yarn workspaces.
- A package builds locally but fails CI with "cannot find module".
- Bumping a version and watching half the workspace break.
- Centralizing common dep versions (catalog protocol).
- Wiring Turborepo / Nx for caching.
- Debugging peer-dependency warnings that look harmless but break runtime.
Core capabilities
Workspace layout
repo/
├── pnpm-workspace.yaml
├── package.json # workspace root, no app deps
├── packages/
│ ├── core/ # @myorg/core
│ ├── ui/ # @myorg/ui
│ └── tsconfig/ # @myorg/tsconfig (shared base)
└── apps/
├── api/ # @myorg/api
└── marketing/ # @myorg/marketing
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
# Catalog protocol — pinned versions referenced by name.
catalog:
react: ^19.0.0
typescript: 5.7.2
zod: ^3.25.0
# Multiple named catalogs for divergent stacks.
catalogs:
legacy:
react: ^18.2.0
In each package:
{
"name": "@myorg/ui",
"dependencies": {
"@myorg/core": "workspace:*",
"react": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@myorg/tsconfig": "workspace:*",
"typescript": "catalog:"
}
}
workspace:* resolves to the local package on install and to a real version range when published. catalog: resolves to whatever the workspace catalog pins. One source of truth, no version drift.
Filtering
# Run a script in one package.
pnpm --filter @myorg/api dev
# In a package and everything that depends on it.
pnpm --filter @myorg/core... build
# In a package and everything it depends on.
pnpm --filter ...@myorg/api build
# Changes since main.
pnpm --filter "...[origin/main]" test
# Multiple filters.
pnpm --filter "@myorg/api" --filter "@myorg/marketing" build
The dot ellipses are dependency direction: pkg... includes downstream consumers; ...pkg includes upstream deps.
.npmrc knobs that matter
# Strict by default — pnpm doesn't hoist. Tools that assume hoisting break.
# Allow specific things to hoist when a tool needs them at the root.
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=@types/*
# When a dep doesn't declare a peer correctly and patching upstream isn't viable.
auto-install-peers=true
# Catalog mode: 'strict' fails the install if a workspace package uses a non-catalog version.
# Encourage the catalog without forcing it during migration.
manage-package-manager-versions=true
# Avoid lifecycle scripts running in CI for postinstalled deps you don't trust.
side-effects-cache-readonly=true
shamefully-hoist=true is a last resort. It defeats pnpm's strictness and reintroduces npm-style implicit deps.
packageExtensions — fix broken peer deps without forking
# pnpm-workspace.yaml
packageExtensions:
some-broken-pkg@*:
peerDependencies:
react: '*'
other-pkg@<2:
dependencies:
buffer: ^6.0.0
This is a non-disruptive way to patch package.json for installed deps. Write the issue link as a comment so future-you knows to remove it after upstream fixes.
Turborepo wiring
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
^build means "build all upstream deps first." Outputs declare what to cache. Persistent: dev servers shouldn't cache.
# Run build, cached by content hash of inputs.
turbo build
# Run only the affected packages since main.
turbo build --filter "...[origin/main]"
TypeScript project references in a workspace
// packages/tsconfig/base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"composite": true
}
}
// packages/ui/tsconfig.json
{
"extends": "@myorg/tsconfig/base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"references": [{ "path": "../core" }]
}
tsc -b builds packages in dependency order with incremental caches. Each package gets a tsconfig.tsbuildinfo next to its outputs.
CI install pattern
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo build --filter "...[origin/main]"
- name: Test
run: pnpm turbo test --filter "...[origin/main]"
--frozen-lockfile fails CI if pnpm-lock.yaml would change. Always use it in CI.
Anti-patterns
Implicit transitive dep
Symptom: App imports lodash (which it doesn't declare) but the build works locally because some other dep transitively pulled it in.
Diagnosis: pnpm's strict resolution hides transitive deps from your code; this works on npm. Until pnpm.
Fix: Declare every dep you import. pnpm why lodash shows who's actually pulling it in.
workspace:* accidentally published
Symptom: Consumer of a published package gets workspace:* in node_modules and chokes.
Diagnosis: Forgot to convert workspace:* → real semver on publish.
Fix: Use pnpm publish (handles the rewrite) or pnpm pack to inspect first. Never run npm publish directly in a pnpm monorepo.
shamefully-hoist=true to "fix" a missing peer
Symptom: App works locally with shamefully-hoist; CI fails when hoisting differs.
Diagnosis: Hiding a real bug under hoisting. Some tool needs a peer that's not declared.
Fix: Find the missing peer, add it to the package's peerDependencies or use packageExtensions. Remove shamefully-hoist.
Turbo cache invalidating on every commit
Symptom: No turbo cache hits despite config looking right.
Diagnosis: inputs not declared — turbo defaults to "everything in the package," so an unrelated file change busts the cache.
Fix: Set inputs per task: ["src/**/*.{ts,tsx}", "package.json", "tsconfig.json"].
Catalog drift via direct edits
Symptom: Half the workspace is on react@^19, half on ^18.
Diagnosis: Someone bumped react in one package's package.json instead of in the catalog.
Fix: Adopt catalog: for shared deps. Set pnpm.overrides for transitive single-version enforcement. Lint with manage-package-manager-versions.
Lifecycle scripts running with secrets in env
Symptom: A postinstall in a transitive dep prints sensitive env vars (or worse).
Diagnosis: pnpm runs lifecycle scripts by default.
Fix: pnpm install --ignore-scripts for installs in CI sensitive paths; allowlist scripts via pnpm.onlyBuiltDependencies.
Quality gates
-
pnpm-lock.yamlcommitted;--frozen-lockfileenforced in CI. - No
workspace:*ranges in published artifacts. - Shared dep versions live in the catalog (
catalog:protocol). - No
shamefully-hoist=true; missing peers fixed viapackageExtensionsor proper declarations. - Turbo task
inputsdeclared so the cache reflects real dependencies. - CI uses
--filter "...[origin/main]"so only affected packages build. - TypeScript project references mirror workspace dependency graph.
- Every package has
engines.nodematching the repo Node version. -
pnpm.onlyBuiltDependenciesallowlist for postinstall scripts.
NOT for
- npm or yarn workspaces — different resolution semantics.
- Bazel / Lerna / Rush — different orchestration models.
- Single-package repos — pnpm works fine, but workspace features don't apply.
- Bun workspaces — Bun's workspace support is similar but has its own quirks. No dedicated skill.
- CI matrix design over the workspace packages — once you have working
pnpm --filter ..., the matrix shape is upstream. →github-actions-matrix-patterns.