modern-react-spa

Chapter 19

Monorepo Configuration — Workspaces, Turborepo, Nx, with a Bun focus

Monorepo configuration for React projects — npm/pnpm/Bun/Yarn workspaces, Turborepo vs Nx, CI affected-only builds, and Changesets versioning.

Published 2026-05-23

🎯 Chapter Goal — After this chapter you can decide whether your project actually benefits from a monorepo (and when it doesn’t), pick between npm / pnpm / Bun / Yarn workspaces with clear-eyed trade-offs, choose Turborepo or Nx for orchestration, and stand up a working Bun workspace from scratch — apps/, packages/, tooling/, the workspace:* protocol, bun --filter, affected-only CI, the works.

🧭 Prerequisites — Ch 11 (Component Library) for the internal-package shape. Ch 17 (Vite) and Ch 18 (Bun & Deno) for the underlying tools. Sample monorepo: examples/ch19-monorepo/.


🔹 19.1 The monorepo case — when it pays, when it doesn’t

“Should we go monorepo?” is one of the most consequential structural decisions a frontend org makes. The wrong answer costs 18 months and a culture argument. Be honest before adopting.

When it pays

  • Multiple apps share live components or tokens. A design system change needs to land in all consumers at once. Without a monorepo, this is publish → version-bump → wait → consume in two or three repos. With it, it’s one PR.
  • Cross-cutting tooling is duplicated. Lint rules, codemods, test utilities, GitHub Actions templates copy-pasted across five repos diverge within a year.
  • Refactors land as multi-PR coordinations. “I need to merge this in repo A, then in repo B, then in repo C, in this order, without deploying any of them yet” — that pain is a monorepo signal.

When it doesn’t pay (yet)

  • Single-app shop with no shared code. A monorepo for one app is overhead with no upside.
  • Teams in wildly different release cadences and the leadership can’t unify them. Daily-deploy product + quarterly-deploy embedded firmware does not belong in one repo.
  • Infrastructure team can’t (or won’t) run a non-trivial CI cache. Without remote caching, monorepo CI minutes blow up. If your platform team is at capacity, defer.

The risks

  • CI minutes explode if every PR rebuilds the world. §19.7 covers the mitigation.
  • Dependency-graph fragility — one bad version bump cascades through every consumer.
  • Ownership confusion — without disciplined CODEOWNERS, “everyone touches everything” becomes the default and nobody owns anything.

Before/after — the directory shape

Before (polyrepo)                After (monorepo)
─────────────────                ──────────────────
acme-admin/                      acme/
  src/                             apps/
  package.json                       admin/
                                     web/
acme-web/                            ops-console/
  src/                             packages/
  package.json                       ui/         ◀── Ch 11 design system
                                     icons/
acme-ui/                             tokens/
  src/                             tooling/
  package.json                       eslint-config/
                                     tsconfig/
                                   package.json   ◀── root workspaces field
                                   bun.lockb      ◀── single lockfile
                                   turbo.json

The headline change is one lockfile and one root package.json with a workspaces field. Everything else is convention.

Decision sketch

Single app, no shared code   ─▶  Keep as-is. No monorepo.
2+ apps, ad-hoc copy-paste   ─▶  Monorepo with internal packages.
3+ orgs, separate cadences   ─▶  Polyrepo with published libs.
Acquisition with two stacks  ─▶  Monorepo *temporarily* while you converge.

🔹 19.2 Workspace flavours — the package-manager layer

The package manager owns four things: node_modules layout, the lockfile, hoisting policy, and the workspace protocol resolver. The flavour you pick affects all four.

19.2.1 npm workspaces

  • Ships with Node — zero extra tool to install.
  • Hoists by default; less strict than pnpm.
  • Lockfile: package-lock.json. Verbose but Git-friendly.
  • Performance: middle of the pack.
  • The conservative default. Pick this if your team has no opinion and you want zero friction.

19.2.2 pnpm workspaces

  • Content-addressable global store; node_modules/ is a symlink farm into ~/.local/share/pnpm/store/.
  • Strictest about phantom dependencies — your code can only import what it explicitly declared. This catches real bugs and frustrates real people.
  • Lockfile: pnpm-lock.yaml. Tidy diffs.
  • Performance: fastest installs for most repos.

19.2.3 🥟 Bun workspaces

  • Part of Bun 1.x. The deep focus of this chapter — see §19.4.
  • Hoisting policy similar to npm; integrated with bun build, bun test.
  • Lockfile: bun.lockb. Binary; not human-readable. (Optional text mirror via BUN_LOCKFILE_FORMAT=text.)
  • Performance: fastest cold install of the four.

19.2.4 Yarn workspaces (classic + Berry)

  • Classic (v1) — large legacy footprint, no longer actively developed.
  • Berry (v3/v4) — PnP mode (no node_modules), strict, but trips up tools that resolve through the file system.
  • Lockfile: yarn.lock.
  • Don’t migrate to Yarn in 2026 unless you have a specific reason. If you’re already on it and not suffering, stay.

19.2.5 Decision table

ConcernnpmpnpmBunYarn (Berry)
Install speed (cold)slowfastfastestmedium
Strict depsnoyesnoyes (PnP)
Lockfile diffabilityyesyesnoyes
Native module compat⚠️ improving
Windows support⚠️ improving
Built-in task runnerweakweakstrongmedium
Workspace protocolyesyesyesyes
Right sizeanyanyanylegacy only

My recommendation for new projects in 2026: Bun for raw speed, pnpm for strictness, npm for zero-friction. Pick by which axis dominates your team’s pain.


🔹 19.3 Task orchestration — Turborepo, Nx, moonrepo

Workspaces give you node_modules symlinked correctly. You still need a way to run “build everything affected by this PR” without rebuilding the world. That’s a different tool.

Turborepo

https://turbo.build — the leading choice in 2026 for SPA monorepos.

  • turbo.json defines a pipeline of tasks with declared inputs, outputs, and dependencies.
  • Local cache by default; remote cache (Turborepo Remote Cache, free tier, or self-hosted) shares the cache across CI runners and developers.
  • turbo run build --filter=...[origin/main] rebuilds only what changed since main.
  • Configuration is shallow: one turbo.json at the root, occasional per-package overrides.

📄 examples/ch19-monorepo/turbo.json (excerpt)

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],         // ← upstream workspaces build first
      "inputs":    ["src/**", "tsconfig.json", "package.json"],
      "outputs":   ["dist/**", "build/**"]
    },
    "test": { "dependsOn": ["build"], "outputs": [] },
    "lint": { "outputs": [] },
    "dev":  { "cache": false, "persistent": true }
  }
}
  • dependsOn: ["^build"] — caret means “this task depends on the same task in upstream workspaces.” Build my deps first.
  • inputs — files Turborepo hashes to compute the cache key. Change one of these, cache invalidates.
  • outputs — what to cache and restore.
  • cache: false, persistent: truedev is a long-running process; don’t try to cache its output.

Nx

https://nx.dev — heavier, plugin-rich, generator-rich.

  • Project graph computed automatically from imports; nx affected:build is the affected-only command.
  • Distributed task execution via Nx Cloud (commercial).
  • Better fit for very large monorepos (50+ apps) and teams that want code generators (“create a new lib of type X with these defaults”).
  • Steeper learning curve. project.json per project plus root nx.json.

moonrepo

https://moonrepo.dev — newer entrant, written in Rust, multi-language support.

  • Watch its maturity curve. Not the safe default in 2026 yet.

Picking one

ConcernTurborepoNx
Learning curveshallowsteep
Configurationone turbo.jsonper-project project.json
Plugins / generatorsminimalrich
Affected detectionby file diffby project graph
Cachelocal + remote (free tier)local + Nx Cloud (paid)
Right size1–30 apps20–500 apps

Default recommendation: Turborepo. Graduate to Nx if you outgrow it.


🔹 19.4 🥟 Bun workspaces — deep dive

This is the section that earns the chapter’s title. Per the user’s stack stance (book §BOOK_SPECS Ch 19), Bun workspaces gets the full treatment.

19.4.1 The root package.json

📄 examples/ch19-monorepo/package.json

{
  "name": "acme",
  "private": true,                                          // ← prevents accidental publish
  "type": "module",
  "workspaces": ["apps/*", "packages/*", "tooling/*"],      // ← glob patterns
  "scripts": {
    "build": "turbo run build",
    "test":  "turbo run test",
    "lint":  "turbo run lint",
    "dev":   "turbo run dev --parallel"
  },
  "devDependencies": {
    "turbo":      "^2.3.0",
    "typescript": "^5.6.0"
  },
  "packageManager": "bun@1.1.0"
}

Two things to notice:

  • workspaces is glob-based. Anything matching apps/* that has its own package.json is a workspace. Add a folder, run bun install, it’s wired up.
  • packageManager field locks the manager + version. corepack (when enabled) refuses other tools.

19.4.2 bun install at the root — what happens

bun install   (run from root)


 ① Read root package.json, expand workspaces globs.
 ② Walk every matched directory, read its package.json.
 ③ Build the unified dep graph: cross-workspace edges become symlinks;
   external edges become npm fetches.
 ④ Write a single bun.lockb at the root.
 ⑤ Hoist shared deps to <root>/node_modules.
 ⑥ For each workspace's private deps, link into <workspace>/node_modules.
 ⑦ Resolve "workspace:*" protocol entries by symlinking into ../../packages/<name>.

The result:

acme/
├── bun.lockb              ← ONE lockfile for the whole repo
├── package.json
├── node_modules/          ← hoisted shared deps (react, etc.)
│   ├── react/
│   └── …
├── apps/
│   └── web/
│       ├── node_modules/  ← symlinks to workspace siblings + private deps
│       │   ├── @acme/ui      → ../../../packages/ui    (symlink)
│       │   └── @acme/tokens  → ../../../packages/tokens (symlink)
│       └── package.json
└── packages/
    └── ui/
        └── package.json

19.4.3 bun --filter <pkg> run <script>

Run a script in a subset of packages. The selector syntax supports globs and graph traversal.

bun --filter "./apps/web" run dev          # ← exact package path
bun --filter "@acme/ui..."   run build     # ← @acme/ui + everything that depends on it
bun --filter "...@acme/admin" run test     # ← everything @acme/admin depends on, plus itself
bun --filter "@acme/*" run lint            # ← all packages matching the scope

The ... graph traversal operators (postfix = downstream consumers, prefix = upstream dependencies) come from the pnpm convention; Bun adopted them. Once you internalize the two, you can express almost any “build just what I touched” CI command.

19.4.4 Cross-package imports — the workspace protocol

📄 examples/ch19-monorepo/apps/web/package.json

{
  "name": "web",
  "dependencies": {
    "@acme/ui":     "workspace:*",
    "@acme/tokens": "workspace:*",
    "react":        "^19.2.0"
  }
}
  • workspace:* — always link the local version. Refuses to install from npm even if a published version exists. The right default for internal packages.
  • workspace:^1.0.0 — link locally if the local version satisfies the range; otherwise install from npm.
  • workspace:~1.0.0 — same but with patch-only range.

On bun publish (when you go from private to published), the workspace specifiers are rewritten to actual published versions. The local-development → published-consumer transition is automatic.

Dev consumption: source vs build output

In dev, an app imports the source of its workspace deps — no rebuild loop. Two ways to wire it:

  1. Direct source export (the example uses this). @acme/ui’s exports map points at ./src/index.ts. The app’s bundler (Vite) transpiles it on the fly.
  2. Built output with a watcher (the production-y path). @acme/ui’s exports map points at ./dist/index.js. A bun --filter "@acme/ui" run build --watch rebuilds on every source change.

Source-export is simpler for development; built-output mirrors what consumers outside the monorepo see. Most teams start with source, switch to built-output once they have external consumers.

19.4.5 bun build for an internal library

Cross-link to Ch 11 §11.3 for the full library-building story. Inside the monorepo:

bun --filter "@acme/ui" run build

…runs whatever build script @acme/ui/package.json defines. Typically bun build src/index.ts --outdir dist --target browser --external react --external react-dom.

Turborepo orchestrates the order: dependsOn: ["^build"] means @acme/tokens builds before @acme/ui builds before apps/web builds.

19.4.6 Pairing Bun workspaces with Turborepo

The two tools own different things:

  • Bun owns: dependency resolution, the lockfile, script execution within a single package, the node_modules layout.
  • Turborepo owns: orchestrating which packages run which scripts in which order, plus caching the results.

You can use Bun without Turborepo (just bun --filter) for small monorepos. You can use Turborepo without Bun (with npm or pnpm). Together, they’re the modern default for SPA monorepos.

📄 combined invocation

# Bun resolves the workspace; Turborepo orchestrates the affected build:
bun run turbo run build --filter=...[origin/main]

19.4.7 Caveats

  • Native modules across workspaces. Bun’s Node-API support is improving but uneven. If your monorepo has packages depending on sharp, better-sqlite3, canvas — test before committing to Bun for workspaces specifically.
  • Windows. Bun’s filesystem watching at scale (40+ packages) has rough edges. Verify on your team’s slowest Windows laptop.
  • bun.lockb diffability. Use BUN_LOCKFILE_FORMAT=text to emit a text mirror, or use bun pm ls in PR descriptions to show what changed.

🔹 19.5 Repo layout — apps/, packages/, tooling/, docs/

acme/
├── apps/                    ◀── deployable units (each = one app, no scope)
│   ├── admin/               ◀── Blueprint-shell internal tool (Ch 10)
│   ├── web/                 ◀── shadcn-styled customer SPA (Ch 7)
│   └── ops-console/         ◀── flexlayout-react workspace (Ch 9)
├── packages/                ◀── libraries consumed by apps (scoped @acme/*)
│   ├── ui/                  ◀── Ch 11 design system
│   ├── icons/               ◀── tree-shaken icon set
│   ├── tokens/              ◀── CSS / JSON / Tailwind preset
│   └── api-client/          ◀── typed fetch wrapper (TanStack Query — Ch 14)
├── tooling/                 ◀── shared configs, not published (scoped @acme/*)
│   ├── eslint-config/
│   ├── tsconfig/
│   └── vitest-config/
├── docs/                    ◀── Storybook lives here (Ch 11.5.7)
├── package.json
├── bun.lockb
└── turbo.json

Naming convention

  • Apps are unscoped. admin, web, ops-console. Apps are deployed, not published; they don’t need a scope.
  • Packages and tooling are scoped @acme/<role>. Use a private scope name unique to your org.

The tooling/ folder convention

tooling/ packages are shared TS configs, ESLint configs, Vitest presets — things every workspace consumes but nobody publishes. Mark them "private": true. Setting them as their own packages (rather than copy-pasted .eslintrc) means a single source of truth.

📄 examples/ch19-monorepo/tooling/tsconfig/package.json

{
  "name": "@acme/tsconfig",
  "private": true,
  "files": ["base.json", "react.json"]
}

Consumers reference it directly:

// apps/web/tsconfig.json
{ "extends": "@acme/tsconfig/react.json", "include": ["src"] }

Change tooling/tsconfig/react.json once, every app picks it up.


🔹 19.6 Versioning across packages — Changesets

Cross-link to Ch 11 §11.7 for the deep treatment. The monorepo-specific decision is fixed vs independent versioning.

  • Fixed mode. Every release bumps every published package together. @acme/ui@1.5.0, @acme/tokens@1.5.0, @acme/icons@1.5.0 — all on the same version. Good when packages are tightly coupled (a design system).
  • Independent mode. Each package versions on its own cadence. Good when packages have different maturity or different consumer bases (an icon set used by the whole company vs an experimental component lib used by one team).

For most React SPA monorepos: independent, with consumers pinned to ranges ("@acme/ui": "^1.x").


🔹 19.7 CI patterns — affected-only builds, remote cache

The affected-only build

📄 .github/workflows/ci.yml (excerpt)

name: CI
on:
  push: { branches: [main] }
  pull_request: { branches: [main] }

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }              # ← full history for affected detection
      - uses: oven-sh/setup-bun@v2
        with: { bun-version: 1.x }
      - run: bun install --frozen-lockfile
      - run: bun run turbo run build test lint --filter=...[origin/main]
        env:
          TURBO_TOKEN:  ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM:   ${{ vars.TURBO_TEAM }}

Two things make this fast:

  1. --filter=...[origin/main] — only rebuild packages downstream of changed files since main.
  2. Remote cache via TURBO_TOKEN + TURBO_TEAM — first CI runner builds; second runner pulls cached output. Local developer machines also pull from the same cache.

A no-op PR (touching only README) hits 100 % cache, completes in <30 s. A single-file change in @acme/ui rebuilds ui + every downstream consumer; everything else cache-hits.

Preview deploys per app

Each app under apps/ gets its own preview URL per PR. Configure per-app in Vercel / Cloudflare Pages with a path filter:

Vercel project: acme-web
  Root directory:     apps/web
  Install command:    cd ../.. && bun install
  Build command:      cd ../.. && bun run turbo run build --filter=web
  Output directory:   dist

Repeat per app. Each PR gets web-pr-123.vercel.app, admin-pr-123.vercel.app. Designers and PMs review per-app.

Cache-key hygiene

  • Pin packageManager in root package.json. Don’t let Renovate auto-bump it without intentional review.
  • Pin turbo version in devDependencies. Same reason.
  • Use a globalEnv list in turbo.json to declare every env var that affects builds; anything not listed is ignored by the cache (and can be changed without invalidation).

🔹 19.8 Migration playbook — single repo → workspaces

The whole migration should fit inside a 4–6 week window. Long-running migrations leak everywhere — feature work slows, contributors lose track of which mental model is current.

Week 1 — layout decision and pilot

  1. Decide layout (apps/web/ for the existing app’s new home).
  2. Move the existing single app into apps/<name>/ without changing its package.json.
  3. Add root package.json with workspaces field.
  4. bun install (or npm install) — verify the single app still builds and tests pass.

Week 2 — first internal package

Extract the package with the least dependency surface. Usually tokens/ or icons/. Move the files, add a package.json, update the app’s imports.

Week 3 — Turborepo

Add turbo.json. Wire build, test, lint pipelines. Verify cache hits on a no-op PR. Set up remote cache.

Week 4 — second internal package + onboarding

Extract the next package (typically the design system). Update contributor docs: where new packages go, how to run scripts, how CI works.

Weeks 5–6 — observability and stabilisation

  • Monitor CI cache hit rate (Turborepo’s summary reports). Aim for 80 %+ on no-op-equivalent PRs.
  • Document the workspace conventions in a MONOREPO.md.
  • Train one developer per team on bun --filter and turbo run --dry.
┌─ Migration timeline (typical) ───────────────────────────┐
│ Week 1   │ Layout + pilot: existing app moves into apps/  │
│ Week 2   │ First package extracted (tokens/ or icons/)    │
│ Week 3   │ Turborepo wired; remote cache live              │
│ Week 4   │ Second package + contributor docs              │
│ Week 5-6 │ Observability + per-team training              │
└──────────────────────────────────────────────────────────┘

⚠️ Never combine a monorepo migration with another major change (framework upgrade, React version bump, build-tool swap). One variable at a time.


🪤 Common Pitfalls

  1. Two lockfiles in the same repo (bun.lockb and package-lock.json). Pick one as authoritative; emit the other only via a CI step if you absolutely need both. Mixed lockfiles cause subtle dep-version drift.
  2. Missing "private": true on the root package.json. Without it, bun publish at the root tries to publish the repo metadata as a package.
  3. App-to-app imports. apps/admin importing from apps/web/src/… is a smell. Workspaces are for packages, not app-to-app cross-talk. If two apps share code, extract it to a packages/ entry.
  4. Public @acme/* scope for internal-only tooling. Anything that should never be published needs "private": true to prevent accidental leaks. Consider an @acme-internal/* scope to make intent visually obvious.
  5. Building everything sequentially in CI when --filter would skip 80 %. Read the Turborepo dry-run output before blaming CI for being slow.
  6. Unpinned turbo or Bun version. Renovate-bumped behind your back, cache invalidates, CI gets slow, nobody knows why. Pin both.
  7. No CODEOWNERS. Monorepos amplify “everyone touches everything.” Without ownership, ambiguous review responsibility leads to the slowest merges of any code-organization style.
  8. Source-export packages built for production. In dev, packages export source for fast HMR. For external (npm-published) consumption, they need a built dist/. The exports map must handle both — or you ship broken tarballs.
  9. workspaces glob too broad. ["**/*"] matches node_modules/*/node_modules/* and chaos ensues. Use specific patterns like ["apps/*", "packages/*"].

✅ Recap

  • Monorepos pay when shared code crosses real org boundaries. Otherwise they’re overhead — be honest before adopting.
  • Pick the package manager by the axis that matters to your team: Bun for speed, pnpm for strictness, npm for zero-friction. Yarn only if you’re already there.
  • Turborepo is the safe default for orchestration in 2026; graduate to Nx if you outgrow Turborepo’s simplicity.
  • Bun workspaces are first-class: workspace:*, bun --filter, bun build, single lockfile.
  • Repo layout: apps/ (deployable, unscoped), packages/ (consumed, scoped @acme/*), tooling/ (private, scoped), docs/ (Storybook). Three conventions, infinite scale.
  • Changesets for versioning; affected-only CI; preview deploys per app; remote cache or you’ll regret it.
  • Migrate over weeks, not in a single mega-PR. One variable at a time.

🔗 Further Reading

In the book — not on the site

Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.

Topics in this chapter (8)
  1. 19.1 The monorepo case — when it pays, when it doesn’t
  2. 19.2 Workspace flavours — the package-manager layer
  3. 19.3 Task orchestration — Turborepo, Nx, moonrepo
  4. 19.4 🥟 Bun workspaces — deep dive
  5. 19.5 Repo layout — apps/, packages/, tooling/, docs/
  6. 19.6 Versioning across packages — Changesets
  7. 19.7 CI patterns — affected-only builds, remote cache
  8. 19.8 Migration playbook — single repo → workspaces