Back to System Design
System Design
medium
mid

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.

8 min read·~5 min to think through

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.ts files.
  • 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:

json
{
  "workspaces": ["packages/*", "apps/*"]
}

Or pnpm-workspace.yaml:

yaml
packages:
  - 'packages/*'
  - 'apps/*'

Workspaces hoist deps so a single node_modules (or virtual store, in pnpm) holds one copy.

2. Internal package linking

json
{
  "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.
jsonc
// 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:

json
{
  "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").

json
{
  "peerDependencies": { "react": ">=18" }
}

TypeScript path mappings

json
{
  "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.

Senior engineer discussion

Seniors set the single-version-policy from day one, enforce isolation with pnpm (or via lint rules), and design the package graph deliberately — apps depend on packages, packages do not depend on apps. They invest in caching early because CI time scales superlinearly with package count, and they automate versioning with changesets to make releases boring.

Related questions