How Nx and pnpm workspaces divide the work
pnpm and Nx each own one half of the monorepo. pnpm owns dependencies. pnpm-workspace.yaml lists which directories are packages. pnpm install resolves every package.json, writes pnpm-lock.yaml, and builds a strict node_modules layout in which internal packages declared with workspace:* are symlinked rather than downloaded. Nx owns execution. It discovers the same packages and reads their declared dependencies, including workspace:* links, to build a project graph. It uses that graph to order tasks, decide what is affected by a change, and compute cache hashes.
The flow
pnpm-workspace.yaml + each package.json → pnpm install → pnpm-lock.yaml + node_modules (symlinked internal packages)
Same package.json files + lockfile → Nx project graph → task ordering, nx affected, cache hashes → cached or executed tasks
Nx does not inspect what your code imports at runtime through node_modules. It trusts the declarations. If api imports @acme/auth but never lists it, the import may still resolve through hoisting or a stray symlink. Nx will then miss the edge. A change to auth won't mark api as affected, and api's cache hash won't change. CI goes green on a stale build.
Most monorepo pain comes from blurring this line, for example by relying on hoisting to paper over missing declarations or by hand-editing the graph to make up for them. Keep declarations accurate in package.json and let pnpm enforce them. Nx's graph is then correct without extra work.
This setup suits teams with many independently versioned packages who want pnpm's install speed and strictness together with Nx's caching and task orchestration.
+---------------------+ +-----------------------------+
| pnpm-workspace.yaml | | package.json (every package)|
| lists package dirs | | deps incl. workspace:* |
+----------+----------+ +------+--------------+-------+
| | |
+----------+----------+ |
v |
+-------------------+ |
pnpm owns | pnpm install | |
| resolve and link | |
+---------+---------+ |
v |
+-------------------------------+ |
| pnpm-lock.yaml | |
| node_modules: strict layout, | |
| internal packages symlinked | |
+---------------+---------------+ |
| lockfile | declared deps
v v
+---------------------------------------------------+
Nx owns | Nx project graph (built from declarations) |
+-------------------------+-------------------------+
v
+---------------------------------------------------+
| task ordering | nx affected | cache hashes |
+-------------------------+-------------------------+
v
[ cache hit: replay | miss: execute ]
pnpm turns pnpm-workspace.yaml and package.json declarations into a lockfile and a strict, symlinked node_modules, while Nx builds its project graph from the same declared dependencies and the lockfile, not from imports, and uses that graph for task ordering, affected detection and cache hashing.
Declaring internal packages with the workspace: protocol
An internal dependency needs to appear in two places: pnpm's symlink tree and Nx's project graph. Declaring it in package.json with the workspace: protocol covers both. Start with a pnpm-workspace.yaml whose packages list contains globs such as apps/* and libs/*. Then give the library a normal name, for example @acme/logger in libs/logger/package.json, and have the consumer, for example apps/api/package.json, list "@acme/logger": "workspace:*" under dependencies.
workspace:* tells pnpm to resolve the name only from the workspace. If no local package matches, the install fails instead of silently pulling a same-named package from the registry. pnpm symlinks the library into the consumer's node_modules. Nx reads the same declaration and adds a graph edge from api to logger.
The protocol only applies inside the repo. When you run pnpm publish or pnpm pack, pnpm rewrites it to a real range based on the library's current version. workspace:* becomes the exact version, workspace:^ becomes a caret range, and workspace:~ becomes a tilde range. Pick the form that matches the range you want external consumers to receive.
Skipping the declaration causes problems that are hard to spot. Nx's static import analysis catches many cross-package imports, but it can miss computed dynamic imports, references from config files or scripts, and code that resolves only because a package was hoisted. A missing edge has two effects. nx affected leaves api out when logger changes. Because the task hash for api no longer includes logger's inputs, Nx also replays a cached build or test result that was produced against the old library code.
# --- pnpm-workspace.yaml (repo root) ---
# Every directory matched here is a workspace package that pnpm can link.
packages:
- "apps/*"
- "libs/*"
# --- libs/logger/package.json ---
# Consumers declare a dependency on this name.
{
"name": "@acme/logger",
"version": "1.4.0",
"main": "dist/index.js"
}
# --- apps/api/package.json ---
# workspace:* makes pnpm resolve @acme/logger only from the workspace and
# symlink it into apps/api/node_modules. Nx reads the same entry and adds
# the api -> logger edge to the project graph.
{
"name": "@acme/api",
"version": "0.1.0",
"dependencies": {
"@acme/logger": "workspace:*"
}
}
# --- apps/api/package.json as written into the tarball by pnpm pack / pnpm publish ---
# workspace:* is rewritten to logger's current version, here the exact 1.4.0.
# With workspace:^ the result would be "^1.4.0", and with workspace:~ it would be "~1.4.0".
{
"name": "@acme/api",
"version": "0.1.0",
"dependencies": {
"@acme/logger": "1.4.0"
}
}
A minimal pnpm workspace in which apps/api declares @acme/logger with workspace:, followed by the dependency entry pnpm writes into the packed or published package.json.*
Centralizing versions with pnpm catalogs
Workspace links handle internal packages. External packages need a separate mechanism, otherwise ten package.json files end up with ten slightly different ranges for the same library. A pnpm catalog fixes this. You declare a version once in pnpm-workspace.yaml, under a catalog key for the default catalog or under catalogs for named ones. Each package then writes catalog: or catalog:<name> instead of a version range. pnpm resolves the reference at install time and replaces it with the real range when you publish.
A single default catalog suits most repos, because the whole point is one version decision per dependency. Add named catalogs only when you need a deliberate split. For example, during a migration most services move to a new major of a framework while one service references a legacy catalog until its code is updated.
| Where it lives | What goes there | Why |
|---|---|---|
Root devDependencies |
Nx, its plugins, TypeScript, linters, formatters | These run across the whole repo and must agree with each other. |
Each package's dependencies |
Runtime libraries that the package imports | This keeps the dependency graph honest for Nx and for deployment. |
Both locations should reference the catalog, so build tooling and runtime libraries stay consistent across packages.
Catalogs also bring trade-offs:
- Every consumer is tied to one version decision.
- A single bump can break many projects at once, so run the full affected set when you change a catalog entry.
- Named catalogs need an owner and an exit date, or they become permanent.
- Catalog support requires a reasonably recent pnpm, and tools that parse
package.jsonmust understand the protocol. Check your own toolchain before you adopt catalogs.
# pnpm-workspace.yaml
packages:
- "apps/*"
- "libs/*"
# Default catalog: one version decision per dependency.
# Packages reference these with "catalog:". Version ranges are placeholders.
catalog:
nx: ^20.0.0
"@nx/js": ^20.0.0 # keep Nx plugins on the same version as nx
typescript: ^5.6.0
eslint: ^9.0.0
express: ^5.0.0
zod: ^3.23.0
# Named catalogs: only for deliberate, temporary splits.
# Packages reference these with "catalog:legacy".
catalogs:
legacy:
# Owner: payments team. Remove once apps/payments-api moves to express 5.
express: ^4.21.0
# Root package.json: repo-wide tooling
# "devDependencies": {
# "nx": "catalog:",
# "@nx/js": "catalog:",
# "typescript": "catalog:",
# "eslint": "catalog:"
# }
#
# apps/orders-api/package.json: runtime libraries it imports
# "dependencies": { "express": "catalog:", "zod": "catalog:" }
#
# apps/payments-api/package.json: still on the old major during the migration
# "dependencies": { "express": "catalog:legacy", "zod": "catalog:" }
A pnpm-workspace.yaml with a default catalog for shared tooling and runtime versions, plus a named 'legacy' catalog that lets one service stay on the previous Express major, with comments showing how the root and package manifests reference each catalog.
Strict node_modules, phantom dependencies and when to loosen hoisting
pnpm writes every package version once into a content-addressable store on disk, then hard-links those files into a virtual store at node_modules/.pnpm. Each package there gets its own node_modules folder containing only the dependencies it declares. Your project's top-level node_modules holds symlinks to its direct dependencies and nothing else.
Because of this layout, Node's module resolution can only find what a package actually lists. With npm or Yarn's classic hoisted layout, a transitive dependency often ends up at the root, so import of an undeclared package works by accident. That is a phantom dependency. It breaks when an upstream package drops the transitive dependency or when Nx builds a library in isolation. pnpm makes these failures show up immediately and locally, instead of later in production.
Hoisting options
| Mode | Setting | What becomes visible | Guarantee given up |
|---|---|---|---|
| Strict (default) | none | Declared dependencies only | None |
| Public hoisting | public-hoist-pattern for named packages |
Matching packages at the root | Strictness for those packages |
| Hoisted linker | node-linker=hoisted |
Flat, npm-style tree | Phantom-dependency detection repo-wide |
Treat each loosening as a targeted exception, and record in the config why it exists. Public hoisting suits tools that resolve plugins from the root, such as some linters and editor integrations. Use node-linker=hoisted only when you have to: for example, a bundler, React Native toolchain or native-module build that walks a flat node_modules. Choosing it means pnpm no longer catches undeclared imports, so your lint rules or CI checks must catch them.
# .npmrc at the workspace root
# Strict layout stays the default: each package sees only its declared deps.
# Every entry below is a deliberate, documented exception.
# EXCEPTION 1: public hoisting for tools that resolve plugins from the root.
# Reason: the editor ESLint integration loads plugins relative to the
# workspace root, not relative to each project.
# Scope: only packages matching these patterns are exposed at the root.
# Note: setting this replaces pnpm's built-in default patterns.
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
# EXCEPTION 2 (disabled): flat, npm-style node_modules for the whole repo.
# Enable only if a bundler, React Native toolchain or native-module build
# walks a flat node_modules and cannot be configured otherwise.
# Cost: pnpm stops catching undeclared imports repo-wide, so lint rules
# or CI checks must catch phantom dependencies instead.
# node-linker=hoisted
# After changing any of these, reinstall so the layout is rebuilt:
# pnpm install
A workspace-root .npmrc that keeps pnpm's strict layout by default, publicly hoists only lint and format tooling with a recorded reason, and leaves node-linker=hoisted commented out with the guarantee it gives up.
Why pnpm-lock.yaml conflicts happen and how to resolve them
A pnpm workspace has one pnpm-lock.yaml at the root, so every team's dependency change lands in the same file. The lockfile records each importer's resolved dependencies and a shared package section, so bumping a widely used library such as a logger or test runner rewrites entries across many projects. Two branches that each touch dependencies will almost always overlap, even when their package.json changes don't.
Resolving a conflict
Do not hand-merge the lockfile. Its entries are cross-referenced, so a merge that looks textually clean can describe a dependency graph that no install would produce. Instead:
- Resolve the
package.jsonconflicts by hand, since those encode intent. - Take either side of
pnpm-lock.yaml, usually the target branch. - Rerun
pnpm installto regenerate the lockfile from the merged manifests. - Review the lockfile diff. If a range like
^or~permits a newer release than the one previously locked, pnpm may resolve to it. That pulls in a transitive update nobody asked for. - Commit the result.
You can register a custom git merge driver in .gitattributes that runs install automatically. This removes the manual steps, but it also hides what changed. It only works if every developer has configured the driver locally, so CI must still verify the result.
Prevention
Use catalogs to centralize shared versions, which keeps bumps to a few predictable lines. Batch dependency updates into dedicated PRs rather than mixing them with feature work. Enforce pnpm through the packageManager field or a preinstall check, so a stray npm install doesn't produce a competing lockfile. That enforcement does add friction for occasional contributors who have to set up the correct tooling first, and that cost is worth it.
CI integration: frozen lockfile, store caching and Nx affected
Lockfile discipline only pays off if CI enforces it. Run the pipeline in this order.
Restore the store, install frozen, then compute affected
First, restore the pnpm content-addressable store from cache. Key it on a hash of pnpm-lock.yaml, so the cache invalidates exactly when resolved dependencies change. Cache the store, not node_modules. The store is portable, while node_modules is a tree of links that pnpm can rebuild quickly from a warm store.
Second, install with --frozen-lockfile. If a package.json change wasn't committed with its matching lockfile update, the install fails immediately. Without the flag, CI would resolve silently and test a dependency graph nobody reviewed.
Third, run nx affected against a meaningful base. On pull requests, that is the merge-base with the target branch. On the main branch, it is the last commit whose pipeline succeeded. Comparing against HEAD~1 misses changes whenever a previous run failed or several commits land together.
The lockfile trap
Nx treats pnpm-lock.yaml as an input. Depending on configuration, any lockfile change can mark every project as affected, or only the projects whose resolved dependencies actually changed. The narrower setting is faster. The risk is that if Nx's dependency analysis misses a transitive or runtime-loaded package, a real breakage gets skipped. A common compromise is to use narrow detection on pull requests and run a full pipeline on main.
Illustrative estimate. Assume 60 projects, 3 minutes of CPU each, 8 parallel agents, and no remote cache hits. If everything is marked affected, that is 180 CPU-minutes, or about 23 minutes of wall time. If accurate detection selects 6 projects, that is 18 CPU-minutes, or about 3 to 6 minutes of wall time depending on dependency ordering.
Migrating an existing Nx repo to pnpm, alternatives and cost
Migration checklist
- Run
pnpm importto convert the existingpackage-lock.jsonoryarn.lockso resolved versions stay the same, or delete the lockfile and reinstall if you accept version drift. - Run every target with
nx run-many. Builds that fail on missing modules point to phantom dependencies. - Fix each one by declaring the dependency in the package that imports it. Don't reach for hoisting first, because hoisting hides the error again.
- Add
public-hoist-patternentries only for tools that need a dependency at the root, and leave a comment explaining each one. - Move shared versions into catalogs one dependency group at a time, so each lockfile diff stays small enough to review.
| Option | Strictness | Disk use | Nx fit |
|---|---|---|---|
| npm workspaces | Low (hoisted) | Copies per install | Good, but the project graph trusts undeclared imports |
Yarn, node_modules linker |
Low (hoisted) | Copies per install | Good |
| Yarn Plug'n'Play | High | Low | Poor; Nx and many plugins assume node_modules |
| pnpm, hoisted linker | Low | Shared store | Good; a stepping stone, not the goal |
| pnpm, isolated (default) | High | Shared store, hard links | Good |
Cost
The upfront cost has two parts: fixing phantom dependencies, and a stretch where CI fails on code that worked locally under the old layout. Weigh that one-time cost against recurring savings:
Illustrative estimate (assumed numbers, not a benchmark): net hours saved per year = (phantom-dependency incidents × hours each) + (lockfile conflicts × hours each) − migration hours. With 10 × 3 + 20 × 1 − 40, the result is 10 hours in year one, and the savings keep accruing in later years.
When it isn't worth it
Skip the migration for small repos with a few packages, where these failures are rare. Also skip it if your toolchain breaks under a strict layout and would need so many hoisting exceptions that the result is a hoisted layout anyway.
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.