DEV Community

Ish Chhabra
Ish Chhabra

Posted on • Originally published at ishchhabra.com

How to build a pnpm monorepo, the right way

At some point every growing application ends up with code that needs to be shared across surfaces. Maybe you started with a web app and then needed to build a mobile app alongside it, and suddenly a whole layer of shared logic — the design system, the business logic, the API layer — needs to live somewhere both can reach. When that happens, you have two options: extract the shared code into a separate repository, or set up a monorepo.

With separate repositories, iterating on shared code becomes a slow, multi-step process: make a change, propagate it to every consumer, and then test to see if it actually works. What should be a quick tweak becomes an afternoon.

A monorepo solves this by keeping shared packages and their consumers in the same place. Changes to shared code are immediately visible to every consumer, making it easier to iterate and test. For this guide, we'll be using pnpm workspaces, which supports hard-linking dependencies (more on that later).

How to set up a pnpm workspace

A pnpm workspace is defined by a pnpm-workspace.yaml at the root that tells pnpm where to find your packages:

pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"
Enter fullscreen mode Exit fullscreen mode
my-monorepo/
├── pnpm-workspace.yaml
├── apps/
│   └── my-app/
│       └── package.json      # depends on @packages/ui
└── packages/
    └── ui/
        ├── package.json      # peerDependency on react
        └── src/
Enter fullscreen mode Exit fullscreen mode

Run pnpm install and pnpm symlinks your workspace packages together. It feels like everything should just work — and for simple cases, it does. But add a library like React — one that assumes only one copy of itself exists in memory — and:

🚨 Danger

Invariant Violation: Invalid hook call. Hooks can only be called inside of the body of a function component.

🚨 Danger

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.

Why two copies? How Node resolves imports

When pnpm installs a workspace package, it symlinks node_modules/@packages/uipackages/ui/. When that package runs require("react"), Node resolves from the symlink target — not from the consuming app:

How Node resolves require('react') from a symlinked workspace package

📝 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.


Injected dependencies

The resolution problem 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.json

{
  "dependencies": {
    "react": "^18",
    "@packages/ui": "workspace:*"
  },
  "dependenciesMeta": {
    "@packages/ui": { "injected": true }
  }
}
Enter fullscreen mode Exit fullscreen mode

Each app gets its own hard-linked copy of the shared package, resolving dependencies correctly

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.

The demo below runs a real workspace in-browser. A shared package returns whichever lodash version gets resolved at runtime — the package pins 4.17.20, consumers have 4.17.21.

Try it: switch between the symlink and injected tabs, then click "Compare both".

What about bundler resolution?

Another approach: configure the app's bundler so peer deps resolve from the app's node_modules. Next.js does this specifically to resolve React to its own vendored copy.

The problem: each consumer has to make the change. That means maintenance overhead — every app that uses the shared package must configure its bundler to resolve React from the app's node_modules. And each consumer might use a different bundler: Next.js uses Webpack/Turbopack, Vite uses esbuild/Rollup, React Native uses Metro. Each has its own config format and resolution rules. With pnpm injected, there's no bundler config needed — it works at the package manager level, regardless of what each consumer uses.


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)

{
  "build": "tsc",
  "prepare": "pnpm build"
}
Enter fullscreen mode Exit fullscreen mode

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

Before install: dist/ doesn't exist. After prepare: dist/ built automatically

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:

  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)

{
  "build": "tsc",
  "postbuild": "pnpm-sync copy",
  "prepare": "pnpm sync:prepare && pnpm build"
}
Enter fullscreen mode Exit fullscreen mode

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 lifecycle: pnpm install → prepare → sync:prepare → build → postbuild → consumers 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)

{
  "build:watch": "tsc-watch --onSuccess \"pnpm postbuild\""
}
Enter fullscreen mode Exit fullscreen mode

The full script setup for a shared package:

packages/ui/package.json

{
  "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"
  }
}
Enter fullscreen mode Exit fullscreen mode

📝 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.json

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "noEmit": false,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*.ts", "src/**/*.tsx"]
}
Enter fullscreen mode Exit fullscreen mode

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 command

tsc --sourceRoot "$(pwd)/src"
Enter fullscreen mode Exit fullscreen mode

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.

With the fix, Cmd+Click now opens the actual source:


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

  • Missing .d.ts files after pnpm install

    Error: "Could not find a declaration file for module '@packages/ui'"

    Why: Stale tsconfig.tsbuildinfo. The incremental build cache can cause tsc to skip recompilation or use outdated information, leading to missing or incorrect .d.ts files.

    terminal

    rm packages/ui/tsconfig.tsbuildinfo
    pnpm --filter @packages/ui build
    

References


Originally published on ishchhabra.com. Follow me there for more.

Top comments (0)