| name | pnpm-workspace-monorepo |
| description | Use when configuring a pnpm-workspaces monorepo, debugging hoisting/peer-dep resolution, adopting the catalog protocol, integrating with Turborepo or Nx, fixing "cannot find module" issues that only happen in CI, or migrating from npm/yarn workspaces. Triggers: pnpm-workspace.yaml setup, packageExtensions for broken peer deps, .npmrc tuning (public-hoist-pattern, shamefully-hoist), workspace protocol (workspace:*), filtering with --filter, version mismatches across packages, "Cannot find module \"foo\"" only after lockfile update, ESM/CJS interop in workspace packages. NOT for npm/yarn-specific workspace bugs, monorepos using Bazel/Lerna/Rush, or single-package projects. |
| category | DevOps & Infrastructure |
| tags | ["pnpm","monorepo","workspaces","turbo","dependencies","typescript"] |
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
packages:
- 'packages/*'
- 'apps/*'
catalog:
react: ^19.0.0
typescript: 5.7.2
zod: ^3.25.0
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
pnpm --filter @myorg/api dev
pnpm --filter @myorg/core... build
pnpm --filter ...@myorg/api build
pnpm --filter "...[origin/main]" test
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
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=@types/*
auto-install-peers=true
manage-package-manager-versions=true
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
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
{
"$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.
turbo build
turbo build --filter "...[origin/main]"
TypeScript project references in a workspace
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"composite": true
}
}
{
"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
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.