Why pnpm Workspaces Symlink node_modules Instead of Duplicating Dependencies
Monorepos often waste gigabytes of disk space because every package gets its own copy of shared dependencies. pnpm workspaces solve this with a single virtual store, symlinked node_modules, and one shared lockfile.
18 Dec 2025, 15:39 UTC

The copy-paste tax in monorepos
When you split an application into multiple packages inside one repository, the default npm or Yarn behavior is to install a separate node_modules folder inside each package. If packages/api and packages/ui both depend on React 18, you end up with two identical copies on disk. That duplication slows CI, bloats Docker layers, and makes lockfile diffs harder to read because every package carries its own dependency history.
pnpm workspaces remove that tax by treating the repository as a single dependency universe. Instead of copying packages into each workspace folder, pnpm installs every unique version once into a shared store and then creates symbolic links—shortcuts that point one directory to another—inside each package’s node_modules. The result is a monorepo where packages/api/node_modules/react and packages/ui/node_modules/react resolve to the exact same files, yet each package still sees only the versions it declared.
How the symlinked store works
pnpm uses a content-addressable store: files are stored once and referenced by a hash of their contents. When you run an install, pnpm populates a virtual store at node_modules/.pnpm inside your repo. That directory contains one folder per unique dependency version, such as react@18.2.0. Each workspace package then gets a node_modules directory filled with symlinks that point into .pnpm.
This structure preserves strict semver resolution. If packages/api requires react@^18.0.0 and packages/ui requires react@^17.0.0, both versions exist in .pnpm, but each package’s symlinks point only to its own compatible version. You get the isolation of separate installs without the disk cost.
All of this is coordinated by a single pnpm-lock.yaml at the repository root. The lockfile records every dependency across every workspace package, so a fresh clone can reproduce the entire graph with one command.
A minimal two-package workspace
The following example creates a repo with two workspace packages. Run all commands from the repository root unless noted.
mkdir my-monorepo && cd my-monorepo
pnpm init
mkdir -p packages/api packages/ui
echo 'packages:\n - "packages/*"' > pnpm-workspace.yaml
Initialize each package and give it a name:
cd packages/api && pnpm init && cd ../..
cd packages/ui && pnpm init && cd ../..
Add dependencies to specific packages using the --filter flag. Replace api and ui with the actual names from each package.json:
pnpm --filter api add express
pnpm --filter ui add react react-dom
Now run a single install from the root:
pnpm install
To verify the symlink structure, inspect the node_modules folder of one package:
ls -la packages/api/node_modules
You should see symlink entries rather than real directories. Each symlink points back to ../../../node_modules/.pnpm/<package>@<version>/node_modules/<package>. The root pnpm-lock.yaml now contains an importers section listing both packages/api and packages/ui with their resolved dependencies.
For a unified view of the dependency tree, run:
pnpm ls
This aggregates the workspace graph so you can confirm that both packages resolve through the same lockfile.
Trade-offs and sharp edges
Workspaces require pnpm 6 or later. Older versions do not understand the pnpm-workspace.yaml file and will fall back to isolated installs, so check your version with pnpm --version before migrating.
The symlink layout can surprise tools that expect a flat node_modules. Most modern bundlers and Node.js runtimes follow symlinks correctly, but some older plugins or test runners may double-count files or fail to locate peer dependencies. If a third-party package ships an incomplete manifest, you can patch it with packageExtensions in pnpm-workspace.yaml:
packageExtensions:
'some-package@1.0.0':
peerDependencies:
missing-peer: '*'
Another limitation involves mixing workspace and non-workspace packages. If a package lives outside the globs defined in pnpm-workspace.yaml but still resolves dependencies from the root, version mismatches can create hoisting conflicts. Keep every internal package inside the workspace globs and let the root lockfile own the full graph.
What to do next
- Confirm pnpm 6+ with
pnpm --version. - Add a
pnpm-workspace.yamlwith globs such aspackages/*. - Run installs and dependency additions from the repository root so the shared lockfile stays authoritative.
- Spot-check symlinks with
ls -la packages/<pkg>/node_modules. - Commit
pnpm-lock.yaml; if an install goes wrong, deletenode_modulesand the lockfile, then runpnpm installagain.
Once the workspace is in place, the disk savings and single-lockfile diffs usually speak for themselves. The main adjustment is trusting the symlink structure—and occasionally teaching a legacy tool to follow it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.