How do you manage shared dependencies in a frontend monorepo?
Shared deps in a monorepo are managed by hoisting (single version at the root), workspace protocols (pnpm/yarn workspaces with workspace:* for internal packages), and version pinning via a single source of truth (single-version policy or syncpack). Tooling: pnpm/yarn/npm workspaces, Turborepo or Nx for task orchestration, changesets for versioning. Key tension: avoiding version drift across packages vs giving teams autonomy.
The core problem
A monorepo has N packages that may each depend on react, lodash, typescript, etc. Without discipline you get:
- Three versions of React loaded at runtime.
- Two TypeScripts producing incompatible
.d.tsfiles. - One package on lodash 4.17.20, another on 4.17.21 — bundle bloat.
- Painful upgrades because no one knows who actually uses what.
Building blocks
1. Workspaces (pnpm / yarn / npm)
package.json at the root declares the workspace:
{
"workspaces": ["packages/*", "apps/*"]
}Or pnpm-workspace.yaml:
packages:
- 'packages/*'
- 'apps/*'Workspaces hoist deps so a single node_modules (or virtual store, in pnpm) holds one copy.
2. Internal package linking
{
"dependencies": {
"@acme/ui": "workspace:*"
}
}The workspace: protocol tells the package manager: use the local version, never npm. On publish, it rewrites to a real version.
3. Single-version policy
Pick a target version for each external dep and enforce it everywhere. Tools:
- syncpack: lints and fixes version mismatches across packages.
- pnpm overrides / yarn resolutions: force a single version regardless of what sub-deps want.
- manypkg: similar lints for yarn/npm workspaces.
// root package.json (pnpm)
{
"pnpm": {
"overrides": {
"react": "18.3.1",
"react-dom": "18.3.1"
}
}
}4. Task orchestration
Turborepo / Nx / Lage cache builds and tests, run only what changed, and respect dependency order:
{
"pipeline": {
"build": { "dependsOn": ["^build"] },
"test": { "dependsOn": ["build"] }
}
}Without this, turbo run build rebuilds the world every time.
5. Versioning for publishing
If packages publish to npm, changesets is the standard:
- Each PR adds a markdown file describing the change.
- A release job consumes them, bumps versions, generates a changelog, publishes.
Common patterns
Hoisted vs isolated (pnpm)
pnpm by default keeps each package's node_modules symlinked from a virtual store — packages cannot import deps they didn't declare. This catches "phantom dependency" bugs.
Yarn/npm hoist by default — convenient but lets packages accidentally rely on deps they didn't declare.
Peer dependencies
For shared libraries (UI kits, hooks), declare React as a peerDependency, not a regular dependency. Otherwise each consumer gets its own React, with broken hooks ("Invalid hook call").
{
"peerDependencies": { "react": ">=18" }
}TypeScript path mappings
{
"compilerOptions": {
"paths": { "@acme/*": ["packages/*/src"] }
}
}Lets internal imports resolve to source, not built output — great for DX, careful for build correctness.
Anti-patterns
- Per-package package-lock files — version drift guaranteed.
- No single-version enforcement — three Reacts in production.
- No build cache — CI runs hours instead of minutes.
- Manual version bumps — somebody always forgets one.
- Sharing code via relative imports across packages — breaks publish.
Concrete recommendation stack
For a typical TypeScript frontend monorepo:
- pnpm (strict isolation, fast, efficient store)
- Turborepo (caching, task orchestration)
- syncpack (version consistency)
- changesets (versioning + publishing)
- eslint with import/no-extraneous-dependencies (catches phantom deps)
What this gets you
- One React version across every app and package.
- Internal package changes immediately visible to consumers (no publish needed locally).
- CI runs in 2 min instead of 20 because Turborepo skips unchanged work.
- Upgrades touch one place, propagate everywhere.
- Releases are mechanical and auditable.
Mental model
Monorepo dep management is enforcement: ensure one version per dep, enforce isolation so phantom deps fail loud, automate orchestration so the build is fast, automate versioning so it's mechanical. Without these, you have a polyrepo with extra steps.
Follow-up questions
- •Why peerDependencies vs dependencies for shared libs?
- •How does pnpm's isolation differ from yarn/npm hoisting?
- •How do you choose between Turborepo and Nx?
- •What's the workflow for upgrading React across 50 packages?
Common mistakes
- •Letting different packages pin different React versions.
- •Using dependencies instead of peerDependencies in shared libs.
- •No build cache — CI runs grow unbounded.
- •Importing across packages via relative paths.
- •Forgetting workspace: protocol — npm tries to fetch from the registry.
Performance considerations
- •pnpm uses content-addressed store: 90%+ disk savings vs hoisted npm. Turborepo remote cache turns CI from minutes to seconds for unchanged packages. Single-version policy cuts shipped bundle size proportionally.
Edge cases
- •Native deps (esbuild, sharp) — single hoisted copy may fail per-arch on CI.
- •Postinstall scripts in deps interact with workspace hoisting.
- •Optional peer dependencies — new in npm 7+.
- •Publishing a single package out of a monorepo — workspace: must be rewritten.
Real-world examples
- •Vercel ships its product in a Turborepo + pnpm monorepo.
- •Shopify uses Nx for their frontend monorepo.
- •Babel uses lerna + yarn workspaces.
- •React itself is a workspaces monorepo.