How pnpm and Nx split the work in a monorepo
In an Nx and pnpm monorepo, each tool owns a different layer. pnpm owns packages. pnpm-workspace.yaml lists which directories are workspace members, and pnpm install resolves their dependencies, writes the lockfile and links local packages to each other. Nx owns tasks. It reads those same workspace members to build a project graph, then uses that graph to decide what to build, test or lint, in what order, and what it can restore from cache.
Version policy follows from this split. Catalogs, overrides, peer-dependency rules and hoisting settings belong in pnpm configuration (pnpm-workspace.yaml and .npmrc), not in nx.json. Nx only sees the dependency edges that pnpm's manifests and lockfile describe.
The layout of pnpm's node_modules is the reason the setup is strict. Package contents live once in a content-addressable store and are hard-linked into node_modules/.pnpm, where each package version gets its own directory. A project's top-level node_modules holds symlinks only for the dependencies that its own package.json declares. If service A imports a library that only service B depends on, Node's resolver will not find it. With npm-style flat hoisting, the same import would quietly resolve. These undeclared imports are called phantom dependencies. They break when B drops the library, and they hide edges that Nx would otherwise use for affected-project detection.
This setup suits backend and platform teams running many services and shared libraries in one repository. These teams need accurate dependency boundaries, a single lockfile and cached, graph-aware CI runs.
+--------------------------------+ +-------------------------------+
| pnpm-workspace.yaml + .npmrc | | nx.json |
| members, catalogs, overrides, | | task targets, cache inputs |
| hoisting, peer rules | | (no version policy) |
+---------------+----------------+ +---------------+---------------+
v v
+--------------------------------+ +-------------------------------+
| pnpm install (owns packages) | | Nx (owns tasks) |
| resolve, write pnpm-lock.yaml, |-->| reads members + manifests |
| link local packages | | -> project graph |
+---------------+----------------+ | -> affected, order, cache |
| +-------------------------------+
v
+--------------------------------+
| content-addressable store |
| (each file stored once) |
+---------------+----------------+
v hard links
+----------------------------------------------------------------+
| node_modules/.pnpm/ one directory per package@version |
| lib-x@2.1.0/ lib-y@1.4.0/ ... |
+-------+---------------------------------------+----------------+
^ symlink (declared) ^ symlink (declared)
+-------+----------------------+ +------------+-----------------+
| services/a/node_modules | | services/b/node_modules |
| lib-y -> .pnpm/lib-y@1.4.0 | | lib-x -> .pnpm/lib-x@2.1.0 |
| import 'lib-x' -> fails | | import 'lib-x' -> resolves|
| (phantom: not declared) | | |
+------------------------------+ +------------------------------+
pnpm owns version policy, installation and the strict symlinked node_modules layout that blocks phantom dependencies, while Nx reads the same workspace to build the project graph that drives task ordering, affected detection and caching.
Setting up the workspace and verifying the project graph
Start with the root package.json. Mark it "private": true so it can never be published by accident, and keep its dependencies limited to workspace tooling such as nx itself. The root is where the lockfile and Nx configuration live. It is not a deployable unit, so runtime dependencies for services do not belong there.
Next, pnpm-workspace.yaml defines which directories pnpm treats as packages. A packages list with globs like apps/* and libs/* tells pnpm where to look for package.json files to link and install. Nx reads the same workspace definition to infer projects, so this file is the shared source of truth for both tools. If a directory is missing from the globs, pnpm will not link it and Nx will not see it.
For internal dependencies, declare them with the workspace: protocol, for example "@acme/auth": "workspace:*" in a service's package.json. This forces pnpm to resolve the package from the repository instead of a registry, and installation fails if the package does not exist locally. The same declaration gives Nx an explicit edge in the project graph. Nx does not have to rely only on source-code import analysis to find it.
After pnpm install, verify detection. nx show projects should list every app and library. nx graph lets you confirm that the edges match your workspace: declarations. A missing project usually means a glob mismatch or a missing name field.
Command names, inference behavior and protocol handling vary between Nx and pnpm releases, so check the documentation for the versions you have installed.
# Root package.json: private so it cannot be published, no service runtime deps
cat > package.json <<'EOF'
{
"name": "acme-monorepo",
"private": true
}
EOF
# Workspace globs: the shared source of truth for pnpm linking and Nx project inference
cat > pnpm-workspace.yaml <<'EOF'
packages:
- "apps/*"
- "libs/*"
EOF
# Minimal Nx config at the root (defaults are enough for package-based inference)
echo '{}' > nx.json
# One library and one app; each needs a "name" field to be detected as a project
mkdir -p libs/auth apps/api
echo '{ "name": "@acme/auth", "version": "0.0.0", "private": true }' > libs/auth/package.json
cat > apps/api/package.json <<'EOF'
{
"name": "@acme/api",
"version": "0.0.0",
"private": true,
"dependencies": { "@acme/auth": "workspace:*" }
}
EOF
# Install Nx as root tooling only (-w targets the workspace root), then link packages
pnpm add -D -w nx
pnpm install
# Verify: both projects should be listed, and the graph should show api -> auth
pnpm exec nx show projects
pnpm exec nx graph
A minimal pnpm workspace with a private root, apps/ and libs/* globs, a workspace:* internal dependency, and Nx commands to confirm every project and edge is detected; behavior can vary by Nx and pnpm version.*
Declaring versions once with pnpm catalogs
With the graph confirmed, move version numbers out of individual manifests. In pnpm-workspace.yaml, a top-level catalog key holds the default catalog, a map of package names to version ranges. Each project's package.json then declares the dependency as "catalog:" instead of a range. The manifest still states what the project depends on, so Nx's graph and pnpm's isolation still work per project. Only the version moves to one place.
When some apps must stay on an older major, add a catalogs key with named entries, for example a legacy catalog that pins the previous major of your web framework. Those apps reference "catalog:legacy", and the rest stay on the default. The split is now an explicit, reviewable decision rather than drift you find later in the lockfile.
Upgrading becomes a two-file change. Edit the range in pnpm-workspace.yaml, run pnpm install, and the diff is that line plus pnpm-lock.yaml. No project manifest changes. Reviewers see the full blast radius in one place, and nx affected picks up every consumer from the lockfile change.
The trade-offs:
- Catalogs are pnpm-specific. Switching package managers means rewriting every reference.
- Other tools may not read them. Some publishing, vulnerability scanning and automated-update tools need configuration or a recent release before they recognise
catalog:. - Publishing depends on rewriting. pnpm replaces
catalog:andworkspace:with real ranges at pack time. Runpnpm packand read thepackage.jsoninside the tarball before you trust a release.
# pnpm-workspace.yaml: the only place version ranges live
packages:
- "apps/*"
- "libs/*"
# Default catalog. Projects reference it with "catalog:"
catalog:
react: ^19.0.0
react-dom: ^19.0.0
typescript: ~5.6.0
# Named catalogs for apps that must stay on another major.
# Projects reference this one with "catalog:legacy"
catalogs:
legacy:
react: ^18.3.1
react-dom: ^18.3.1
# apps/storefront/package.json uses the default catalog:
# "dependencies": { "react": "catalog:", "react-dom": "catalog:" }
#
# apps/admin-legacy/package.json is pinned to the old major on purpose:
# "dependencies": { "react": "catalog:legacy", "react-dom": "catalog:legacy" }
#
# libs/ui/package.json (published) can mix both protocols:
# "dependencies": { "@acme/tokens": "workspace:^", "react": "catalog:" }
#
# Upgrade flow: edit one range above, then
# pnpm install # diff = this file + pnpm-lock.yaml only
#
# Before publishing, confirm pnpm rewrote both protocols to real ranges:
# cd libs/ui && pnpm pack
# tar -xOzf acme-ui-*.tgz package/package.json
# # expect "react": "^19.0.0", not "catalog:"
A pnpm-workspace.yaml with a default catalog and a named legacy catalog, the package.json references that consume each one, and the commands to upgrade through a single edit and to check the packed manifest before release.
Single version policy: root-only dependencies vs per-project catalogs
With every third-party package in the root package.json, the lockfile describes one dependency tree. Nx still keeps affected detection precise: when a version changes, it marks only the projects whose imports resolve to that package, not the whole repo. The cost appears elsewhere. A publishable library has no manifest of its own to declare runtime dependencies, so you need a build step or lint rule that derives them from imports. Without one, the published package can ship with missing or overly broad dependencies.
Per-project manifests that reference the default catalog keep one version per package while making each project's dependencies explicit. Publishing works as the previous section described, and CODEOWNERS can map a directory's package.json to its team. The trade-off is repetition: each new dependency is an edit in two files.
| Concern | Root-only dependencies | Per-project + catalogs |
|---|---|---|
| Affected detection | Inferred from imports | Inferred from imports and manifests |
| Cache invalidation | Scoped to importing projects | Scoped to declaring projects |
| Library publishing | Needs derived manifests | Native rewrite at pack time |
| Code ownership | One shared file | Per-directory manifests |
| Verbosity | Low | Higher |
Both models enforce a single version policy, and both make upgrades all-or-nothing. If one app can't move to the new framework major, the whole repo waits. A named catalog for that app unblocks it, but it brings back two versions. Each named catalog should have an owner and a removal date.
Root-only fits applications that are deployed, not published. Use per-project catalogs when you publish libraries or need per-team ownership. Avoid root-only if consumers install your packages from a registry.
Hoisting controls and fixing undeclared-dependency errors
pnpm's strictness comes from its layout. Each project sees only the packages it declares, linked from the content-addressed store. Two .npmrc settings loosen that layout in a controlled way. hoist-pattern controls what is hoisted into the hidden node_modules/.pnpm/node_modules. Dependencies can resolve each other from there, but your source code cannot. public-hoist-pattern hoists matching packages into the root node_modules, where every project can import them. This is the narrow escape hatch for tools that resolve plugins from the workspace root rather than from the config file's location. Some ESLint plugin setups and older CLI tools behave this way. Add one specific pattern per tool. Next to each pattern, write a comment naming the tool and explaining why it needs root resolution.
| Setting | What it changes | Phantom dependencies |
|---|---|---|
public-hoist-pattern (narrow) |
Listed packages appear in root node_modules |
Only for listed packages |
shamefully-hoist=true |
Equivalent to public-hoisting everything | Allowed repo-wide |
node-linker=hoisted |
Flat, npm-style node_modules, no symlinks |
Allowed repo-wide |
The last two settings exist for tooling that cannot handle symlinks or strict resolution. Enabled across the repo, they give up correctness to gain compatibility. Code that imports an undeclared package will build and pass tests. It breaks later, when a transitive dependency drops that package or the library is installed outside the monorepo.
When you see Cannot find module, fix the manifest that is actually missing the dependency:
- Your own project imports the package: add the dependency to that project's
package.json. Nx's@nx/dependency-checkslint rule can catch this before CI does. - A third-party package forgot to declare a dependency: patch its manifest with
packageExtensionsor areadPackagehook in.pnpmfile.cjs. Add a comment linking the upstream issue.
Review these patches on each upgrade cycle and delete the ones that upstream has fixed.
# .npmrc at the workspace root
#
# Keep pnpm's strict layout. Each hoisting exception below is narrow,
# names the tool that needs it, and should be reviewed when that tool
# is upgraded.
#
# Note: setting public-hoist-pattern replaces pnpm's default list.
# Restate any default patterns you still rely on.
# Internal hoisting into node_modules/.pnpm/node_modules.
# Dependencies can resolve each other from here, but project source
# code cannot.
hoist-pattern[]=*
# Public hoisting into the root node_modules. Only add tools that
# resolve plugins from the workspace root, not from their config file.
# ESLint: shared config loads plugins by name from the root.
# Remove once the config imports plugins directly (flat config).
public-hoist-pattern[]=eslint-plugin-*
public-hoist-pattern[]=@typescript-eslint/*
# Legacy CLI that requires its peer from the root and cannot follow
# symlinks. Tracked for removal when the tool is replaced.
public-hoist-pattern[]=legacy-cli-plugin-*
# Do NOT enable these repo-wide. They let phantom dependencies return
# silently. Fix the missing manifest entry, or use packageExtensions
# for a broken third-party package instead.
# shamefully-hoist=true
# node-linker=hoisted
A root .npmrc that keeps pnpm strict, publicly hoists only specific tool packages with a documented reason for each, and leaves the repo-wide hoisting modes disabled.
Removing duplicate framework instances caused by peer dependencies
pnpm installs a package once per unique combination of the package version and the versions of its resolved peer dependencies. If ui-kit resolves react to one version in apps/web and to another in apps/admin, pnpm creates two virtual-store entries for ui-kit, each linked to its own copy of React. Each install is correct by pnpm's rules, but the runtime now has two module instances.
That breaks anything that relies on module identity. React stores the current dispatcher in module-level state, so a component rendered by one copy and hooks imported from another produce "invalid hook call" errors. DI containers that key providers by class reference fail to resolve a token because the class registered and the class requested are different objects. ORMs that collect decorator metadata in a module-scoped registry miss entities defined against the other copy. instanceof checks fail silently across the boundary.
Detecting duplicates
Run pnpm why -r react from the root to see every path that pulls React in, across all workspace packages. pnpm list -r react --depth Infinity shows resolved versions per project. More than one version, or the same library listed with different peer suffixes in node_modules/.pnpm, means you have split instances.
Removing them
Put the framework in the catalog and reference it with catalog: from every app's dependencies and every library's devDependencies. Libraries should declare the framework in peerDependencies with an identical range across the workspace, so no package widens or narrows what the others accept. Then run pnpm dedupe and re-check with pnpm why. One version should remain.
CI enforcement, upgrade workflow and impact on engineering time
Keep CI honest
Run pnpm install --frozen-lockfile in CI. If someone edits a package.json or the catalog without committing the matching lockfile, the install fails, so drift never reaches a green build. Add a lint step that walks every workspace package.json and fails if a dependency listed in the catalog uses a literal range instead of catalog:. Without that check, one hard-coded version quietly brings back the skew you just removed.
Lockfile changes feed into Nx in two ways. External dependency versions are part of each task's hash, so a changed resolution invalidates cached results only for projects that depend on that package. For affected detection, Nx parses the lockfile to find projects touched by changed packages. Depending on your Nx version and configuration, a lockfile edit may mark only those projects as affected or the whole workspace. Check this in your setup with nx show projects --affected before you rely on it.
Upgrade sequence
- For Nx's own packages, run
nx migratefirst or together with the catalog bump, so code migrations run against matching versions. - Bump the catalog entry.
- Reinstall.
- Run
nx affectedfor lint, test and build. - Review the lockfile diff for unexpected transitive changes.
Cost
Expect breakage up front, because consolidating versions exposes code that only worked against one specific version. You get that time back on later upgrades.
Illustrative estimate (assumed numbers, not a benchmark). Saved hours per upgrade = (P − 1) × h, where P is the number of packages pinning the dependency and h is the hours per package edit and review. With P = 12 and h = 0.5, that is 5.5 hours saved per upgrade, before counting the version-skew bugs you avoid.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nx pnpm workspaces, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.