Skip to content

Fix: pnpm Workspace Not Working — workspace:* Protocol, Catalog, Filters, and Hoisting Issues

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix pnpm workspace errors — workspace:* not resolving, catalog versions out of sync, --filter not matching, peer deps unmet across packages, shamefully-hoist trade-offs, and publishConfig for releases.

The Error

You add a workspace dependency with workspace:* and pnpm install fails:

ERR_PNPM_NO_MATCHING_VERSION_INSIDE_WORKSPACE 
In packages/app: "@my-org/shared@workspace:*" is in the dependencies but no package named "@my-org/shared" is present in the workspace

Or the package resolves locally but pnpm publish ships a wrong-looking dependency:

{
  "dependencies": {
    "@my-org/shared": "workspace:*"
  }
}

(It should be replaced with a real version on publish.)

Or your --filter pattern matches nothing:

$ pnpm --filter ./apps/web build
No projects matched the filters

Or shared peers like react install at different versions in different packages and tests fail with Invalid hook call:

Invalid hook call. Hooks can only be called inside of the body of a function component.
This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)

Why This Happens

pnpm’s workspace model is stricter than npm’s:

  • Strict node_modules. Each package only sees the dependencies it explicitly declares. This catches accidental imports of transitive deps — and breaks code that relied on them.
  • workspace:* is a protocol, not a version range. It tells pnpm “link to the local copy.” On publish, pnpm rewrites it to the real version of the local package. If that package isn’t in pnpm-workspace.yaml, the protocol can’t resolve.
  • --filter matches against name from package.json and against path globs. Misuse (wrong glob, wrong name pattern) produces “matched nothing” silently.
  • Catalogs unify versions across packages. Without them, each package can pull a different React, leading to “two React copies” runtime bugs.

The fifth and least-documented source of pain is dependency graph fan-out in CI. When packages/shared changes, pnpm’s --filter "...[origin/main]" correctly rebuilds shared plus everything that depends on it. In a 20-package monorepo with shared at the root of the graph, that’s the entire monorepo every time. The promised “incremental build” turns into a full rebuild that takes 12 minutes per PR, and developers stop trusting the CI signal. The fix isn’t pnpm-specific — it’s keeping deep-graph packages stable and pushing churn out to the leaves — but it shows up as a pnpm workspace complaint.

Production Incident Lens: When a Monorepo Build Freezes All Deploys

The classic pnpm workspace production incident: someone merges a PR that touches packages/shared, the CI pipeline builds, one downstream package (packages/legacy-app that nobody owns) fails to compile because of a TypeScript change in shared. The pipeline goes red. Now every workspace in the monorepo is blocked from deploying until the legacy app builds — because most CI configurations gate deploys on the whole pipeline going green. A two-character refactor in shared becomes a deploy freeze across five product teams.

The blast radius is asymmetric: the team that owns shared made a small change with good intent. The team that owns legacy-app is on vacation. The team that owns mobile-api (which has nothing to do with either) can’t ship a hotfix to production because the monorepo build is red. This is the single most common reason teams decide to “split up the monorepo” — not the merge conflicts, not the build time, but the cross-team deploy coupling that any single broken package creates.

The production-incident playbook for this has three layers. First, make CI failures per-package, not per-monorepo. Each workspace gets its own deploy gate; a failure in legacy-app blocks only legacy-app deploys. Implement this with per-package GitHub Environments or per-package Cloudflare Pages projects rather than a single monolithic deploy step. Second, split tests from typecheck from build in CI; a typecheck failure in one package should not block the build artifacts for unrelated packages. Third, use --filter "...[origin/main]" aggressively so PRs only run the affected subgraph — but verify the dependency graph is shaped so that subgraph is actually small. If every package depends on shared, the filter buys you nothing.

When the deploy freeze hits, the temporary unblock is pnpm --filter='!@my-org/legacy-app' run build to skip the broken package entirely, plus a manual override to let the unaffected packages ship. The permanent fix is to own the deploy boundary at the workspace level: every package has a CODEOWNER, a green build, and an independent deploy path. Without that, you’ve built distributed-system coupling on top of git, and one bad commit takes down everyone’s release.

The monitoring signal for this class of incident is CI duration per workspace and deploy frequency per workspace. When deploy frequency drops to zero for a workspace whose owners are actively pushing PRs, the workspace is stuck behind another workspace’s failure. Alert on that — it’s the leading indicator that monorepo coupling is hurting velocity.

Fix 1: Declare All Packages in pnpm-workspace.yaml

Every package directory must be covered by the packages field:

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
  - "tools/*"

After editing, re-run pnpm install. Verify with:

pnpm list -r --depth -1
# Lists every workspace package the resolver knows about.

If @my-org/shared isn’t listed, the glob didn’t match. Check the directory structure — the name in packages/shared/package.json must be @my-org/shared, and the directory must be under one of the globs.

Common Mistake: Adding a new package directory but forgetting to run pnpm install at the root. The lockfile is stale until you do.

Fix 2: Use workspace:* (or workspace:^) in Internal Dependencies

In apps/web/package.json:

{
  "dependencies": {
    "@my-org/shared": "workspace:*"
  }
}

Three forms:

  • workspace:* — always link to the local version, regardless of what it is.
  • workspace:^ — link to local, publish as ^X.Y.Z (caret of the current local version).
  • workspace:~ — link to local, publish as ~X.Y.Z.
  • workspace:1.2.3 — link to local, publish as 1.2.3 (exact).

For internal apps that don’t publish, workspace:* is simplest. For published libraries that depend on each other, prefer workspace:^ so consumers get semver-compatible releases.

Pro Tip: Use pnpm add @my-org/shared --workspace --filter @my-org/web to add the dependency with the correct workspace: protocol — avoids hand-editing package.json.

Fix 3: Configure publishConfig for Publishable Packages

When you pnpm publish a workspace package, pnpm rewrites workspace: protocols to real versions in the published package.json. But you also want to control which files ship:

{
  "name": "@my-org/shared",
  "version": "1.2.3",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist", "README.md"],
  "publishConfig": {
    "access": "public",
    "main": "./dist/index.js",
    "exports": {
      ".": {
        "import": "./dist/index.mjs",
        "require": "./dist/index.cjs",
        "types": "./dist/index.d.ts"
      }
    }
  }
}

publishConfig fields override the top-level on publish. Useful when:

  • You want different exports paths in published vs source (src/ vs dist/).
  • You need access: "public" for scoped packages on the public npm registry.
  • You’re using module for ESM and want a published consumer-friendly main.

Fix 4: --filter Patterns

--filter accepts package names, paths, and special selectors:

# By package name
pnpm --filter @my-org/web build
pnpm --filter "@my-org/*" build           # All packages in the @my-org scope

# By path
pnpm --filter "./apps/**" build           # All apps
pnpm --filter "./packages/shared" build   # One specific package by path

# By dependents — build a package and everything that depends on it
pnpm --filter ...@my-org/shared build

# By dependencies — build a package and what it depends on
pnpm --filter @my-org/web... build

# Changed since main branch
pnpm --filter "[main]" build

The ... syntax is powerful for incremental rebuilds. To rebuild only what changed since main:

pnpm --filter "...[origin/main]" build

This selects all packages with changes since origin/main plus every package that depends on them, transitively. Cuts build time dramatically in CI.

Common Mistake: Quoting filters wrong on Windows. PowerShell eats the * in --filter @my-org/*. Quote explicitly: pnpm --filter "@my-org/*" build.

Fix 5: Use Catalogs to Pin Shared Versions

Catalogs (added in pnpm 9) let you define versions once at the workspace level and reference them from each package:

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

catalog:
  react: ^19.0.0
  react-dom: ^19.0.0
  typescript: ^5.6.0

catalogs:
  testing:
    vitest: ^2.0.0
    "@testing-library/react": ^16.0.0

In each package.json:

{
  "dependencies": {
    "react": "catalog:",
    "react-dom": "catalog:"
  },
  "devDependencies": {
    "vitest": "catalog:testing",
    "@testing-library/react": "catalog:testing"
  }
}

catalog: (no name) refers to the default catalog. catalog:testing refers to a named one. Upgrade React across the entire monorepo by changing one line in pnpm-workspace.yaml.

Note: Catalogs require pnpm 9.0+. On older versions you’ll see Unsupported version reference: catalog:.

Fix 6: Hoisting and Strict Peer Resolution

pnpm puts dependencies in node_modules/.pnpm/ and symlinks only the declared deps into each package’s node_modules/. This is strict-by-default — code that does require("lodash") without declaring lodash will fail.

If a tool you can’t modify needs hoisting:

# .npmrc (at workspace root)
public-hoist-pattern[]=*types*
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

This selectively hoists packages matching the patterns into the root node_modules. It’s the safe alternative to shamefully-hoist=true, which dumps everything at the root and defeats the point of pnpm’s strictness.

For React projects with hook-call errors caused by duplicate React installs:

# .npmrc
public-hoist-pattern[]=react
public-hoist-pattern[]=react-dom

Combined with a catalog entry, this guarantees exactly one React in the entire workspace.

Fix 7: Patching Dependencies

When a third-party package has a bug, patch it in place:

pnpm patch some-package
# pnpm creates a temporary copy. Edit files there.
pnpm patch-commit /tmp/abc123
# pnpm writes the patch to patches/ and updates package.json.
{
  "pnpm": {
    "patchedDependencies": {
      "[email protected]": "patches/[email protected]"
    }
  }
}

Commit the patches/ directory. Every pnpm install re-applies the patch.

Pro Tip: Patches are tied to specific versions. When the package upgrades, the patch may not apply. Either pin the version in package.json or be ready to re-create the patch on each upgrade.

Fix 8: CI: Use Frozen Lockfile

In CI, always install with frozen lockfile to catch drift:

# .github/workflows/ci.yml
- run: pnpm install --frozen-lockfile

--frozen-lockfile fails if pnpm-lock.yaml would be modified by the install. This catches:

  • A package.json change not reflected in the lockfile.
  • A lockfile committed without running pnpm install first.
  • A catalog version updated but pnpm install not re-run.

Pair with corepack to pin the pnpm version itself:

{
  "packageManager": "[email protected]"
}

Enable in CI:

- run: corepack enable
- run: pnpm install --frozen-lockfile

Still Not Working?

A few less-obvious failures:

  • ENOENT: no such file or directory for a workspace package. The package was added to pnpm-workspace.yaml but its directory doesn’t exist yet. Create the directory with a minimal package.json before installing.
  • workspace:* shipped to npm un-rewritten. You published with npm publish instead of pnpm publish. Only pnpm publish rewrites the protocol. Use it (or wire up Changesets / Release Please which use it internally).
  • Two copies of react after install. Either the catalog isn’t applied (check pnpm-workspace.yaml), or one package declares react as a peer dep and ships its own dev version. Run pnpm why react to trace.
  • pnpm install slow in CI even with cache. Cache the ~/.local/share/pnpm/store directory. actions/setup-node doesn’t cache it by default — use pnpm/action-setup or set up caching manually.
  • tsc -b rebuilds everything despite Turborepo cache. TypeScript references invalidate on package.json changes. Either narrow the deps that affect the cache key, or use tsc --build --incremental with cached tsbuildinfo files.
  • pnpm install deletes node_modules of a package. A prepare script in that package fails, pnpm rolls back. Check the package’s prepare/postinstall script for errors.
  • --filter includes the root package unexpectedly. The workspace root counts as a package with name from root package.json. Filter it out with --filter !@root-name if it’s interfering.
  • PowerShell on Windows: pnpm exec doesn’t find local binaries. Use pnpm dlx for one-off runs, or add the local node_modules/.bin to PATH inside the command.

One Broken Package Blocks All Workspace Deploys

CI is set up to gate the entire monorepo on a single pipeline status, so a build failure in one workspace blocks unrelated packages from shipping. Split deploys per workspace: each package gets its own deploy job that runs only if that package’s build succeeded. Use --filter per job so a mobile-api deploy doesn’t wait on legacy-app to compile. The CODEOWNERS file should mirror the deploy boundary so the right team gets paged when their package breaks.

pnpm install Wipes Catalog Updates on Other Branches

When a branch updates catalog: in pnpm-workspace.yaml, switching to another branch leaves you with the wrong lockfile because pnpm doesn’t re-resolve on branch switch. Run pnpm install after every branch checkout that touches the workspace file, or wire a git hook to do it for you. Otherwise local builds succeed against versions the lockfile doesn’t pin.

Patched Dependencies Disappear After Upgrade

pnpm patch files are tied to exact versions, and when Renovate or Dependabot bumps the version, the patch silently stops applying — but the install still succeeds. Add a CI step that runs pnpm install --frozen-lockfile and then greps the install output for WARN .* patch .* could not be applied. Without this, you ship the bug the patch was supposed to fix.

Workspace Package Resolves at Build Time but Fails at Runtime

If a package imports a workspace dependency that wasn’t built before the importing package’s build step ran, you get a runtime MODULE_NOT_FOUND even though the install succeeded. Use a build orchestrator (Turborepo, Nx, or pnpm -r --workspace-concurrency=1 run build for small graphs) that respects dependency order. Always build leaves before roots.

For related package manager and monorepo issues, see pnpm peer dependency error, Turborepo not working, npm eresolve unable to resolve dependency tree, and npm err peer dep conflict.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles