| | |

pnpm 08 📦 pnpm Workspaces and Monorepo Architecture Management

⏱️ Estimated read: 8 min | |

A monorepo stores multiple packages in a single repository. pnpm Workspaces is the mechanism that makes this practical: it installs all packages’ dependencies together into a single virtual store, links packages to each other without publishing to a registry, and provides commands to run scripts across the entire project. The architecture is defined by a single configuration file—pnpm-workspace.yaml—which tells pnpm which directories contain packages. Unlike npm and Yarn, which use the workspaces field in package.json, pnpm keeps the workspace definition separate from the manifest. This separation is deliberate: the root package.json remains a package manifest, and the workspace configuration is an independent file that pnpm reads before installing anything.

Key point: A pnpm workspace is any directory matched by a glob in pnpm-workspace.yaml that contains a package.json. The root package.json is always part of the workspace, but it must be private: true if it is not intended for publication.


Why pnpm Workspaces exist

The duplication problem. Without workspaces, a monorepo with three packages that all depend on React installs React three times into three separate node_modules directories. pnpm’s global store eliminates the duplication at the file level—React is stored once and hard-linked—but the workspace adds a second layer: a single lockfile, a single resolution step, and a single set of dependency versions for the entire project.

The linking problem. Package A depends on Package B, both in the same repository. Without a workspace protocol, Package A must reference Package B by a version number that exists on the registry. The workspace: protocol tells pnpm to resolve the dependency to the local workspace regardless of the version, and replaces the protocol with the actual version only when the package is published.

The orchestration problem. Running a build across ten packages requires running ten commands in dependency order. pnpm -r run build executes the script in every workspace. The --filter flag narrows the scope to specific packages, and the --filter ... syntax includes dependencies. The topological sort ensures that a package’s dependencies build before the package itself.

The configuration problem. The root pnpm-workspace.yaml is also the place for shared settings: catalog versions (shared dependency versions across workspaces), patched dependencies, and package extensions. These are defined once and applied everywhere.

The trade-off. Workspaces reduce duplication and simplify cross-package development, but they introduce a single point of failure. A broken script in one workspace can affect the entire repository’s CI. Every workspace must declare its own dependencies; pnpm’s strictness enforces this, but the discipline is the responsibility of the developers.


a. Defining the workspace

The workspace is defined in pnpm-workspace.yaml at the repository root. It contains a list of glob patterns. Any directory matching a pattern and containing a package.json is a workspace package.

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
  - '!**/test/**'

The ! prefix excludes a path from the workspace. The glob patterns are resolved relative to the root. The typical layout separates applications (apps/) from shared libraries (packages/).

The root package.json must have "private": true. This prevents accidental publication of the workspace root.

{
  "name": "my-monorepo",
  "private": true,
  "devDependencies": {
    "typescript": "^5.5.0"
  }
}

After defining the workspace and creating the sub-packages, pnpm install at the root resolves and installs all workspace dependencies. The resulting lockfile is at the root, and the virtual store (.pnpm) is at the root. Each workspace has its own node_modules, but those directories contain only symlinks to the packages that workspace declared.


b. Linking workspaces with the workspace protocol

To depend on another workspace, the package.json of the consuming package uses the workspace: protocol. The range can be workspace:*, workspace:^, or an explicit version.

{
  "name": "@myorg/app",
  "dependencies": {
    "@myorg/utils": "workspace:*"
  }
}

The workspace:* protocol tells pnpm to resolve the dependency to the local workspace. During pnpm install, pnpm creates a symlink from @myorg/app‘s node_modules to the @myorg/utils workspace. When the package is published to a registry, the protocol is replaced with the actual version of @myorg/utils at the time of publication.

This is the same mechanism used by Yarn Berry, and it solves the “local development vs published package” problem without requiring a separate publishing step for internal dependencies.


c. Filtering and topological execution

The --filter (-F) flag selects specific workspaces for execution. The ... suffix includes dependencies, and the ^... prefix includes dependents.

# Run build in all workspaces
pnpm -r run build

# Run build in the app package and its dependencies
pnpm --filter @myorg/app... run build

# Run build in the app package and its dependents
pnpm --filter ...@myorg/utils run build

The ... suffix is the most useful filter for monorepos. pnpm --filter foo... run build builds foo and every package that foo depends on. The execution order is topological: dependencies are built before the packages that depend on them.

For workspace management, pnpm -r exec runs an arbitrary command in every workspace. This is useful for one-off operations like generating files or checking configuration.


Complete Example Session

# ============================================
# PART 1: CREATE THE WORKSPACE ROOT
# ============================================

mkdir my-monorepo && cd my-monorepo
pnpm init
# Edit package.json: add "private": true
# ============================================
# PART 2: DEFINE THE WORKSPACE
# ============================================

cat > pnpm-workspace.yaml <<'EOF'
packages:
  - 'apps/*'
  - 'packages/*'
EOF
# ============================================
# PART 3: CREATE A WORKSPACE PACKAGE
# ============================================

mkdir -p packages/utils
cd packages/utils
pnpm init
# Edit package.json: "name": "@myorg/utils"
# ============================================
# PART 4: LINK A WORKSPACE
# ============================================

# In apps/web/package.json:
# "dependencies": { "@myorg/utils": "workspace:*" }
# ============================================
# PART 5: INSTALL EVERYTHING
# ============================================

cd ../..
pnpm install
# The virtual store is at the root.
# Workspace packages are linked via symlinks.
# ============================================
# PART 6: RUN SCRIPTS ACROSS WORKSPACES
# ============================================

pnpm -r run build
# Runs "build" in every workspace that defines it.
# ============================================
# PART 7: FILTER BY DEPENDENCY
# ============================================

pnpm --filter @myorg/app... run build
# Builds @myorg/app and its dependencies.
# ============================================
# PART 8: SHARE DEPENDENCY VERSIONS (CATALOG)
# ============================================

# pnpm-workspace.yaml:
# catalog:
#   react: ^19.0.0

# In workspace package.json:
# "dependencies": { "react": "catalog:" }
# ============================================
# PART 9: EXEC IN ALL WORKSPACES
# ============================================

pnpm -r exec pwd
# Prints the directory of every workspace.

The nine parts trace the workspace lifecycle: root creation, workspace definition, package creation, linking, installation, script execution, filtering, shared versions, and arbitrary execution.


Quick Reference

Workspace Configuration

FilePurpose
pnpm-workspace.yamlDefines packages and shared settings
Root package.json"private": true required
workspace:*Links to local workspace

Filtering Syntax

PatternMeaning
--filter <name>Specific workspace
--filter <name>...Workspace and its dependencies
--filter ...<name>Workspace and its dependents
--filter '!<name>'Exclude workspace

Recursive Commands

CommandPurpose
pnpm -r run <script>Run script in all workspaces
pnpm -r exec <cmd>Execute arbitrary command in all workspaces
pnpm -r testRun tests in all workspaces

Best Practices

✅ Do This:

packages:
  - 'apps/*'
  - 'packages/*'
  • ✅ Use explicit glob patterns
{ "private": true }
  • ✅ Root must be private
{ "dependencies": { "@myorg/utils": "workspace:*" } }
  • ✅ Use workspace: protocol for internal deps

❌ Don’t Do This:

{ "name": "root" }
  • ❌ Missing private: true breaks workspace
# Rely on hoisted dependency without declaring it
  • ❌ Breaks when package is published
pnpm -r run build
  • ❌ Without topological sort, build order may be wrong

Common Pitfalls

PitfallWhy It HappensFix
Workspaces ignoredRoot not private: trueAdd "private": true
Internal dep not linkedNo workspace: protocolUse workspace:*
Wrong build order-r without topological sortUse --filter <name>...
Filter matches nothingWrong package nameCheck name in package.json
Catalog not appliedWrong syntax in pnpm-workspace.yamlVerify catalog: syntax

Real-World Examples

1. Define Workspace

packages:
  - 'apps/*'
  - 'packages/*'

2. Link Internal Package

{ "dependencies": { "@myorg/ui": "workspace:*" } }

3. Run in All Workspaces

pnpm -r run build

4. Build App and Dependencies

pnpm --filter @myorg/app... run build

5. Build Utilities and Dependents

pnpm --filter ...@myorg/utils run build

6. Catalog for Shared Version

catalog:
  react: ^19.0.0

7. Reference Catalog

{ "dependencies": { "react": "catalog:" } }

8. Exec in All Workspaces

pnpm -r exec pwd

9. Filter by Directory

pnpm --filter "./apps/*" run test

10. Check Workspace List

pnpm m ls

Visual

Monorepo Structure

┌──────────────────────────────────────────────┐
│  my-monorepo/                                │
│  ├── pnpm-workspace.yaml                     │
│  ├── package.json (private: true)            │
│  ├── pnpm-lock.yaml                          │
│  ├── node_modules/                           │
│  │   └── .pnpm/ (global virtual store)       │
│  ├── apps/                                   │
│  │   ├── web/                                │
│  │   │   └── package.json                    │
│  │   └── api/                                │
│  │       └── package.json                    │
│  └── packages/                               │
│      └── utils/                              │
│          └── package.json                    │
└──────────────────┬───────────────────────────┘
                   │
                   │ pnpm install
                   ▼
┌──────────────────────────────────────────────┐
│  All dependencies linked from the store.     │
│  Workspace packages linked via symlinks.     │
└──────────────────────────────────────────────┘

Filter Execution

┌──────────────────────────────────────────────┐
│  pnpm --filter @myorg/app... run build       │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Include @myorg/app and its dependencies:    │
│  1. @myorg/utils (no deps)                   │
│  2. @myorg/ui (depends on utils)             │
│  3. @myorg/app (depends on ui, utils)        │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Build runs in topological order.            │
└──────────────────────────────────────────────┘

Summary

ItemValue
Workspace definitionpnpm-workspace.yaml
Root requirement"private": true
Internal dependencyworkspace:*
Run all workspacespnpm -r run <script>
Filter with dependenciespnpm --filter <name>... run
Filter with dependentspnpm --filter ...<name> run
Shared versionscatalog: in pnpm-workspace.yaml
Arbitrary commandpnpm -r exec <cmd>

Key takeaways:

  • pnpm Workspaces are defined in pnpm-workspace.yaml, not package.json. The glob patterns list the directories that contain packages. The root must be private: true.
  • The workspace: protocol links internal packages. It resolves to the local workspace regardless of registry versions, and the range determines the published manifest.
  • The --filter flag is the primary orchestration tool. The ... suffix includes dependencies, and the ... prefix includes dependents. The execution order is topological.
  • pnpm -r runs across all workspaces. The run subcommand executes scripts; exec runs arbitrary commands.
  • Catalogs share dependency versions. A catalog: entry in pnpm-workspace.yaml replaces duplicated version ranges across workspaces.

Remember: A pnpm workspace is a directory matched by pnpm-workspace.yaml that contains a package.json. The root owns the lockfile and the virtual store. The workspace: protocol links packages without a registry. The --filter command narrows the scope. The -r flag expands it. Together, these features turn a collection of packages into a single, coherent project.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!