pnpm Workspaces: The Complete Monorepo Guide (2026)
Master pnpm workspaces for monorepo development. Covers workspace protocol, catalogs feature, filtering, recursive scripts, comparison with npm workspaces and Yarn Berry, and migrating from Lerna.
The monorepo pattern has won. Whether youβre maintaining a component library alongside an application, sharing utilities across multiple services, or running a full-stack project where frontend and backend live together β the monorepo approach reduces duplication and makes refactoring dramatically easier.
pnpm workspaces have emerged as the go-to solution: faster than npm workspaces, more pragmatic than Yarn Berry, and with a disk efficiency advantage that compounds at scale. This guide covers everything you need to build a production monorepo with pnpm in 2026.
Why pnpm for Monorepos?
Content-addressable storage: pnpm stores packages in a global store (~/.pnpm-store) and hardlinks them into node_modules. A package used in 10 workspaces only occupies disk space once.
Strict dependency isolation: by default, packages can only import what they explicitly list in their package.json. No phantom dependencies leaking from parent node_modules.
Speed: pnpm install is typically 2β3Γ faster than npm and comparable to Yarn Berry with zero-installs.
Standards-compliant: pnpm uses Node.js module resolution properly. No .pnp.cjs magic.
Setting Up a pnpm Workspace
1. Create the workspace root
mkdir my-monorepo && cd my-monorepo
pnpm init
2. Configure pnpm-workspace.yaml
# pnpm-workspace.yaml
packages:
- "packages/*"
- "apps/*"
- "tools/*"
This tells pnpm to treat every directory under packages/, apps/, and tools/ as a workspace package.
3. Project structure
my-monorepo/
βββ pnpm-workspace.yaml
βββ package.json # Root package β workspace-level scripts/deps
βββ .npmrc # pnpm configuration
βββ packages/
β βββ ui/ # Shared component library
β β βββ package.json # name: "@myorg/ui"
β βββ utils/ # Shared utilities
β β βββ package.json # name: "@myorg/utils"
β βββ config/ # Shared configs (eslint, tsconfig)
β βββ package.json # name: "@myorg/config"
βββ apps/
β βββ web/ # Next.js app
β β βββ package.json
β βββ docs/ # Docusaurus site
β βββ package.json
βββ tools/
βββ scripts/ # Build/deploy scripts
βββ package.json
4. Root .npmrc
# .npmrc
link-workspace-packages=true
prefer-workspace-packages=true
The Workspace Protocol
When one workspace package depends on another, use the workspace: protocol instead of a version number:
{
"name": "@myorg/web",
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/utils": "workspace:^",
"@myorg/config": "workspace:~"
}
}
workspace:*β use the local version exactly (most common)workspace:^β use local, but when publishing resolve to a semver rangeworkspace:~β use local, resolve to patch-level semver range on publish
The workspace protocol ensures that during development you always use the local version, never accidentally pulling from the npm registry.
Installing Dependencies
# Install a dep in a specific workspace
pnpm --filter @myorg/web add react react-dom
# Install a dev dep in a specific workspace
pnpm --filter @myorg/ui add -D typescript
# Install in ALL workspaces
pnpm -r add lodash
# Install at the root (workspace-level tooling)
pnpm add -D -w turbo typescript
The -w flag installs to the workspace root.
The Catalogs Feature (pnpm 9+)
Catalogs are pnpmβs killer feature for monorepos in 2026. They let you define canonical package versions in one place and reference them across all workspaces β solving the βdependency version driftβ problem.
Defining a catalog
# pnpm-workspace.yaml
packages:
- "packages/*"
- "apps/*"
catalog:
react: ^19.0.0
react-dom: ^19.0.0
typescript: ^5.5.0
zod: ^4.0.0
vitest: ^2.0.0
eslint: ^9.0.0
Using catalog entries in workspace packages
{
"name": "@myorg/web",
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"typescript": "catalog:"
}
}
The catalog: specifier tells pnpm βuse the version defined in the workspace catalog.β Every package using react: "catalog:" is guaranteed to resolve the same version.
Multiple catalogs
For projects with different version tiers (stable vs. experimental):
catalog:
react: ^19.0.0
catalogs:
react18:
react: ^18.3.0
react-dom: ^18.3.0
react19:
react: ^19.0.0
react-dom: ^19.0.0
{
"dependencies": {
"react": "catalog:react19"
}
}
Catalogs make upgrades trivial: change one version in pnpm-workspace.yaml, run pnpm install, and every package is updated simultaneously.
Filtering and Recursive Commands
pnpmβs --filter flag lets you target specific workspaces:
# Run a script in one package
pnpm --filter @myorg/ui build
# Run in all packages matching a glob
pnpm --filter "./packages/*" build
# Run in a package and everything that depends on it
pnpm --filter "@myorg/utils..." build
# Run in a package and all its dependencies
pnpm --filter "...@myorg/utils" build
# Run in packages changed since main branch
pnpm --filter "[origin/main]" test
The ... syntax is powerful for CI: only rebuild what actually changed and its dependents.
Recursive commands
# Run "build" in all workspaces (respects dependency order)
pnpm -r build
# Run "test" in parallel across all workspaces
pnpm -r --parallel test
# Run in specific workspaces
pnpm -r --filter "./apps/*" dev
Root-Level Scripts
Your root package.json is the control center:
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r test",
"lint": "pnpm -r lint",
"dev:web": "pnpm --filter @myorg/web dev",
"dev:docs": "pnpm --filter @myorg/docs dev",
"typecheck": "pnpm -r typecheck",
"clean": "pnpm -r --parallel exec -- rm -rf dist node_modules/.cache"
}
}
Shared TypeScript Configuration
// packages/config/tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
// apps/web/tsconfig.json
{
"extends": "@myorg/config/tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"baseUrl": "src"
},
"include": ["src"]
}
pnpm Workspaces vs npm Workspaces vs Yarn Berry
npm Workspaces
npm workspaces work but lack polish. No equivalent to catalogs, --filter is less expressive, and the hoisting behavior can lead to phantom dependency bugs:
# npm equivalent of pnpm's --filter with dependents
npm run build --workspace=@myorg/ui # no transitive dependent support
Choose npm workspaces when: you want zero additional tooling and your monorepo is simple.
Yarn Berry (Yarn 4)
Yarn Berry is feature-rich with PlugβnβPlay, zero-installs, and constraints (a Prolog-based dependency policy engine). Itβs powerful but complex:
- Zero-installs require committing
.yarn/cacheto git (can be gigabytes) - PnP breaks tools that expect a traditional
node_modulesstructure - Patching upstream packages via
yarn patchis genuinely useful
Choose Yarn Berry when: you need Yarn Constraints or PnPβs strict isolation model.
pnpm Workspaces
The pragmatic middle ground: fast, disk-efficient, with excellent filtering support and the catalogs feature. Works with all Node.js tooling out of the box.
Choose pnpm when: you want the best balance of speed, features, and compatibility.
Migrating from Lerna
Lernaβs original purpose (linking local packages and versioning) is now handled natively by pnpm workspaces + changesets:
Step 1: Add pnpm-workspace.yaml
Convert your lerna.json packages array to pnpm-workspace.yaml:
packages:
- "packages/*"
Step 2: Replace lerna bootstrap
# Old
lerna bootstrap
# New
pnpm install
Step 3: Replace lerna run
# Old
lerna run build
# New
pnpm -r build
Step 4: Replace lerna add
# Old
lerna add react --scope=@myorg/web
# New
pnpm --filter @myorg/web add react
Step 5: Replace versioning with Changesets
pnpm add -D -w @changesets/cli
pnpm changeset init
Changesets handles versioning and changelog generation β itβs what Lernaβs version command did but better.
CI/CD with pnpm Workspaces
GitHub Actions
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # needed for --filter "[origin/main]"
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
# Only test what changed
- run: pnpm --filter "[origin/main]..." test
# Build everything
- run: pnpm -r build
Caching the pnpm store
- uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
Common Gotchas
1. Phantom dependencies
pnpmβs strict mode prevents phantom dependencies. If you get βCannot find module βXββ even though X is installed in a sibling workspace, you need to add it to that packageβs package.json:
pnpm --filter @myorg/web add X
2. Build order matters
If @myorg/web depends on @myorg/ui, you must build @myorg/ui first. Either use a build tool like Turborepo (which understands your dependency graph), or manually order your -r scripts:
pnpm --filter @myorg/ui build && pnpm --filter @myorg/web build
3. Circular dependencies
pnpm will detect circular workspace dependencies and error out. Restructure your packages to avoid cycles.
4. Lockfile conflicts
pnpm-lock.yaml is a single file at the workspace root. In large teams, merge conflicts are common. The resolution: accept one version and run pnpm install again.
Recommended Stack for pnpm Monorepos in 2026
- Task runner: Turborepo (understands dependency graph, caches builds)
- Versioning: Changesets (changelog generation, versioning)
- TypeScript: shared
tsconfig.base.jsonvia a@myorg/configpackage - Linting: Flat config ESLint 9 shared from root
- Testing: Vitest (workspace-aware)
- Build: Vite 6 or tsup per package
# Full setup
pnpm add -D -w turbo @changesets/cli vitest typescript eslint
Conclusion
pnpm workspaces in 2026 are the pragmatic monorepo choice: fast, disk-efficient, and with the catalogs feature solving the dependency versioning problem that plagued earlier monorepo setups. Combined with Turborepo for task caching and Changesets for versioning, you have a complete, production-grade monorepo system that scales from 2 packages to 200.
Start simple β two packages, a shared config, and a root-level build script. Add Turborepo when builds get slow. Add Changesets when you need to publish packages. The tools compose well and you only need what you use.
Related tools on DevPlaybook:
- Package.json Generator β scaffold package.json with best practices
- npm to pnpm Migration Checker β audit your project for pnpm compatibility
- Monorepo Structure Visualizer β visualize your workspace dependency graph
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links β we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.