The problem with shared packages#
You're building an app. It grows. You extract shared code into a workspace package — a design system, some utilities, a data layer. pnpm-workspace.yaml, workspace packages, pnpm install. Everything should just work.
The shared package declares a peerDependency on React (or any library with internal state). It also has React in devDependencies for local type-checking. Your app has React in dependencies. You run the app:
Invariant Violation: Invalid hook call. Hooks can only be called inside
of the body of a function component.Error: No QueryClient set, use QueryClientProvider to set oneThe first error is React saying "I found two copies of myself." The second is TanStack Query saying "the provider exists, but I can't see it" — because it's looking in a different React instance's Context tree.
How Node resolves imports#
When pnpm installs a workspace package, it creates a symlink from your app's node_modules/@packages/ui pointing to packages/ui/. When code inside that package runs require("react"), Node resolves from the symlink target — the package's directory on disk — not from the consuming app's directory.
Node walks up from packages/ui/dist/, finds packages/ui/node_modules/react (the devDependency), and stops. It never reaches apps/my-app/node_modules/react. Even if both are the exact same version, your app loads its own copy separately. Two Reacts in memory, two dispatchers, two Context trees.
With two apps, it gets worse#
With one app, both copies might be the same version — duplicate state, but at least the same API. With two apps that need different versions, it gets worse. The shared package's devDependencies can only pin one version. Whichever app doesn't match ends up with not just two instances — but two different versions.
Why isolation matters#
Tools like Nx recommend keeping all packages on the same version of every dependency. That works when one person (or a small team) owns the entire repo. On a larger project with multiple teams, it creates a coordination problem: upgrading React 18 → 19 becomes an all-or-nothing migration that blocks every team until every app is ready.
Setting shared-workspace-lockfile=false in .npmrc gives each package its own pnpm-lock.yaml. This is the foundation that makes everything else possible:
- No cross-team merge conflicts. A single shared lockfile changes every time any team adds or updates a dependency. On an active repo, that means constant lockfile conflicts in PRs. Per-package lockfiles scope changes — updating a dependency in one package only touches that package's lockfile. Other teams' PRs are unaffected.
- Easier review. A lockfile diff scoped to one package is straightforward to review. A diff in a shared lockfile that spans 50 packages is not.
- Gradual migration. One app can move to React 19 while others stay on 18. Migrate one consumer at a time, test it, ship it, move on. No big-bang upgrades.
- Independent tooling. Team A upgrades ESLint 8 → 9 without waiting for Team B. Each package evolves on its own schedule. Consumers import from
dist/, so internal tooling changes don't ripple outward.
The peer resolution problem from the previous section is solved separately with injected dependencies — covered next. Per-package lockfiles give you the independence; injected deps give you correct resolution. Together, they make the monorepo behave as if each package were published to npm and installed separately.
Injected dependencies#
The resolution bug happens because Node walks from the symlink target. To fix it, we need the shared package's code to resolve from the consumer's node_modules instead. pnpm has a flag for exactly this: dependenciesMeta.injected: true.
Instead of creating a symlink to packages/ui/, pnpm creates a hard-linked copy of each file inside the consumer's node_modules. When the shared code runs require("react"), Node resolves from the consumer's directory tree — and finds the consumer's React. Not the package's.
{
"dependencies": {
"react": "^18",
"@packages/ui": "workspace:*"
},
"dependenciesMeta": {
"@packages/ui": { "injected": true }
}
}Each consumer gets its own copy. Each copy resolves from the consumer's tree. App A gets React 18. App B gets React 19. One shared package, correct resolution everywhere.
Proving it with a test#
Here's a minimal test from a test repo that proves the behavior:
// Returns whichever version of lodash gets resolved at runtime
module.exports = function getLodashVersion() {
return require("lodash/package.json").version;
};{
"name": "example-lib",
"main": "index.js",
"peerDependencies": { "lodash": "*" },
"devDependencies": { "lodash": "4.17.20" }
}Two consumers — both have lodash 4.17.21. One uses default symlinks, one uses injected:
| Consumer | Resolved version |
|---|---|
| Symlink (default) | 4.17.20 — package's devDep |
| Hardlink (injected dependency) | 4.17.21 — consumer's version ✓ |
Making it work in practice#
Injected dependencies fix the resolution problem, but they introduce DX challenges. Let's solve them one by one.
Problem 1: Manual builds on a fresh clone#
You clone the repo, run pnpm install, start the app — and it fails. dist/ doesn't exist yet. You have to manually build every shared package before any consumer can import from it. And if packages depend on each other, you have to build them in the right order — leaf packages first, then their consumers, up the chain.
The prepare lifecycle hook solves this — it runs automatically on pnpm install:
{
"build": "tsc",
"prepare": "pnpm build"
}Now pnpm install automatically builds the package. One command, dist/ exists, consumers can import.
Problem 2: Changes don't propagate#
You change a component in the shared package. Nothing happens in the consuming app. With injected deps, consumers get a hard-linked copy of dist/ created at install time. When tsc rebuilds, it writes new files with new inodes — but the consumer's hard links still point to the old ones. The consumer doesn't see the new build.
This is where pnpm-sync (by TikTok) comes in. It re-copies dist/ into each consumer's node_modules after every build:
pnpm-sync prepare— writes a.pnpm-sync.jsonconfig describing which consumers need copies and where their stores are.pnpm-sync copy— reads that config and copiesdist/into each consumer.
We wire these into the existing scripts. The prepare hook now also writes the sync config, and postbuild runs the copy after every build:
{
"build": "tsc",
"postbuild": "pnpm-sync copy",
"prepare": "pnpm sync:prepare && pnpm build"
}On a fresh clone: pnpm install → prepare runs → writes sync config → builds dist/ → postbuild fires → pnpm-sync copy syncs output to consumers. One command and everything is ready.
During development, we want pnpm-sync copy to run after every successful recompilation too. tsc --watch doesn't support running a command on success, so we swap it for tsc-watch, which adds an --onSuccess hook:
{
"build:watch": "tsc-watch --onSuccess \"pnpm postbuild\""
}The full script setup for a shared package:
{
"name": "@packages/ui",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"postbuild": "pnpm-sync copy",
"prepare": "pnpm sync:prepare && pnpm build",
"build:watch": "tsc-watch --onSuccess \"pnpm postbuild\"",
"sync:prepare": "pnpm sync:prepare:my-app",
"sync:prepare:my-app": "pnpm-sync prepare -l ../../apps/my-app/pnpm-lock.yaml -s ../../apps/my-app/node_modules/.pnpm"
},
"peerDependencies": { "react": "^18 || ^19" },
"devDependencies": {
"@types/react": "^18 || ^19",
"tsc-watch": "^7.1.1",
"typescript": "^5"
}
}"sync:prepare": "pnpm sync:prepare:app-a && pnpm sync:prepare:app-b" — each pointing to its consumer's lockfile and store path.Problem 3: Cmd+Click opens .d.ts, not source#
Since consumers import from dist/, Cmd+Click in VSCode opens Link.d.ts instead of Link.tsx. Unlike using transpilePackages in Next.js or similar bundler-level source resolution, our approach lets each package own its build tooling. The tradeoff is that we need declaration maps to get good navigation.
Enable declarationMap: true in the package's tsconfig:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}But even with declaration maps, VSCode still opens .d.ts files. There's an open bug (TypeScript #62009) where VSCode doesn't follow declaration maps when the source root is relative.
The workaround: pass an absolute sourceRoot at build time using $(pwd):
tsc --sourceRoot "$(pwd)/src"At build time, $(pwd) expands to the package's absolute path. The declaration maps embed this absolute path, so VSCode resolves to the actual source file. Add this flag to both your build and build:watch scripts.
Why not Bun?#
Bun's isolated installs provide strict dependency isolation. But even with isolated installs, workspace packages are still symlinked to their source directory. There's no dependenciesMeta.injected equivalent. No hard-linked copies. Resolution still follows from the package's directory.
Same test, same setup — package has lodash 4.17.20 as devDep, consumer has 4.17.21:
| pnpm (injected) | Bun (isolated) | |
|---|---|---|
| Resolved version | 4.17.21 (consumer's) ✓ | 4.17.20 (package's) ✗ |
| Workspace packages | Hard-linked copy | Symlinked to source |
| Fix for peer deps | Built-in (one flag) | Requires bundler config |
Without an injected equivalent, Bun can't correctly resolve peer dependencies for workspace packages. That's why I still use pnpm for monorepos.
Troubleshooting#
References#
- pnpm dependenciesMeta.injected
- TikTok pnpm-sync
- npm scripts lifecycle (prepare)
- TypeScript #62009 — declaration map sourceRoot
- Bun isolated installs
- pnpm shared-workspace-lockfile
- TikTok — Subspaces: Divide and conquer your npm upkeep
That's all. Hope this saves you the hours of debugging that I went through.
Found an issue? Open a PR or send me an email.