Writing

Building a Monorepo That Actually Scales

A practical guide to pnpm monorepos with true package isolation.

Ish ChhabraFeb 9, 202620 min readWritten with AI
The story

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.

my-monorepo/APPSmy-appPACKAGESui

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 one

The 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 MODULE RESOLUTION (SYMLINKS)1Your app imports @packages/uiSymlink resolves to packages/ui/dist/2Shared code runs require("X")Node begins module resolution3Node resolves from the symlink targetStarting directory: packages/ui/ (not apps/my-app/)4Walks up: packages/ui/node_modules/XFound! This is X @ 1.0 — the package's devDependency5Resolution stops. Node never checks the app.apps/my-app/node_modules/X (v2.0) is never reached6Two copies of X in memoryv1.0 from the package + v2.0 from the app = broken state

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.

Note
Any library with module-level state breaks when duplicated: React (hook dispatcher, Context), event emitters, caches, registries. If it stores state in a module closure, loading it twice creates two invisible parallel universes that can't communicate.

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.


The why

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.


The fix

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.

apps/my-app/package.jsonjson
{
  "dependencies": {
    "react": "^18",
    "@packages/ui": "workspace:*"
  },
  "dependenciesMeta": {
    "@packages/ui": { "injected": true }
  }
}
App Adep X @ 2.0@packages/ui (copy)hard-linked into appnode_modules/Xv2.0 ✓App Bdep X @ 3.0@packages/ui (copy)hard-linked into appnode_modules/Xv3.0 ✓injectedinjectedresolvesresolvesEach app gets its ownversion of X ✓

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:

packages/example-lib/index.jsjs
// Returns whichever version of lodash gets resolved at runtime
module.exports = function getLodashVersion() {
  return require("lodash/package.json").version;
};
packages/example-lib/package.jsonjson
{
  "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:

ConsumerResolved version
Symlink (default)4.17.20 — package's devDep
Hardlink (injected dependency)4.17.21 — consumer's version ✓

DX

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:

packages/ui/package.json (scripts)json
{
  "build": "tsc",
  "prepare": "pnpm build"
}

Now pnpm install automatically builds the package. One command, dist/ exists, consumers can import.

AFTER INSTALLnode_modules/@packages/ui/├─ package.json└─ dist/dist/ doesn't exist yetprepare → buildAFTER PREPAREnode_modules/@packages/ui/├─ package.json├─ dist/├─ index.js└─ index.d.tsdist/ built automatically ✓

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:

  1. pnpm-sync prepare — writes a .pnpm-sync.json config describing which consumers need copies and where their stores are.
  2. pnpm-sync copy — reads that config and copies dist/ 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:

packages/ui/package.json (scripts)json
{
  "build": "tsc",
  "postbuild": "pnpm-sync copy",
  "prepare": "pnpm sync:prepare && pnpm build"
}

On a fresh clone: pnpm installprepare runs → writes sync config → builds dist/postbuild fires → pnpm-sync copy syncs output to consumers. One command and everything is ready.

FRESH CLONE LIFECYCLE1pnpm installResolves deps, creates injected copies2prepareRuns automatically on install3sync:prepareWrites .pnpm-sync.json config4build (tsc)Creates dist/5postbuildpnpm-sync copy → syncs to consumersConsumers have built output ✓

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:

packages/ui/package.json (scripts)json
{
  "build:watch": "tsc-watch --onSuccess \"pnpm postbuild\""
}

The full script setup for a shared package:

packages/ui/package.jsonjson
{
  "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"
  }
}
Note
Multiple consumers? Chain the prepare scripts: "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:

packages/ui/tsconfig.jsonjson
{
  "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):

build commandbash
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.


Bun

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 version4.17.21 (consumer's) ✓4.17.20 (package's) ✗
Workspace packagesHard-linked copySymlinked to source
Fix for peer depsBuilt-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.


Debugging

Troubleshooting#


References#

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.