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/, theworkspace:*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 → consumein 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 viaBUN_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
| Concern | npm | pnpm | Bun | Yarn (Berry) |
|---|---|---|---|---|
| Install speed (cold) | slow | fast | fastest | medium |
| Strict deps | no | yes | no | yes (PnP) |
| Lockfile diffability | yes | yes | no | yes |
| Native module compat | ✅ | ✅ | ⚠️ improving | ✅ |
| Windows support | ✅ | ✅ | ⚠️ improving | ✅ |
| Built-in task runner | weak | weak | strong | medium |
| Workspace protocol | yes | yes | yes | yes |
| Right size | any | any | any | legacy 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.jsondefines 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 sincemain.- Configuration is shallow: one
turbo.jsonat 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: true—devis 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:buildis 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.jsonper project plus rootnx.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
| Concern | Turborepo | Nx |
|---|---|---|
| Learning curve | shallow | steep |
| Configuration | one turbo.json | per-project project.json |
| Plugins / generators | minimal | rich |
| Affected detection | by file diff | by project graph |
| Cache | local + remote (free tier) | local + Nx Cloud (paid) |
| Right size | 1–30 apps | 20–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:
workspacesis glob-based. Anything matchingapps/*that has its ownpackage.jsonis a workspace. Add a folder, runbun install, it’s wired up.packageManagerfield 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:
- Direct source export (the example uses this).
@acme/ui’sexportsmap points at./src/index.ts. The app’s bundler (Vite) transpiles it on the fly. - Built output with a watcher (the production-y path).
@acme/ui’sexportsmap points at./dist/index.js. Abun --filter "@acme/ui" run build --watchrebuilds 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_moduleslayout. - 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.lockbdiffability. UseBUN_LOCKFILE_FORMAT=textto emit a text mirror, or usebun pm lsin 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:
--filter=...[origin/main]— only rebuild packages downstream of changed files sincemain.- 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
packageManagerin rootpackage.json. Don’t let Renovate auto-bump it without intentional review. - Pin
turboversion indevDependencies. Same reason. - Use a
globalEnvlist inturbo.jsonto 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
- Decide layout (
apps/web/for the existing app’s new home). - Move the existing single app into
apps/<name>/without changing itspackage.json. - Add root
package.jsonwithworkspacesfield. bun install(ornpm 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 --filterandturbo 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
- Two lockfiles in the same repo (
bun.lockbandpackage-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. - Missing
"private": trueon the rootpackage.json. Without it,bun publishat the root tries to publish the repo metadata as a package. - App-to-app imports.
apps/adminimporting fromapps/web/src/…is a smell. Workspaces are for packages, not app-to-app cross-talk. If two apps share code, extract it to apackages/entry. - Public
@acme/*scope for internal-only tooling. Anything that should never be published needs"private": trueto prevent accidental leaks. Consider an@acme-internal/*scope to make intent visually obvious. - Building everything sequentially in CI when
--filterwould skip 80 %. Read the Turborepo dry-run output before blaming CI for being slow. - Unpinned
turboor Bun version. Renovate-bumped behind your back, cache invalidates, CI gets slow, nobody knows why. Pin both. - No
CODEOWNERS. Monorepos amplify “everyone touches everything.” Without ownership, ambiguous review responsibility leads to the slowest merges of any code-organization style. - Source-export packages built for production. In dev, packages export source for fast HMR. For external (npm-published) consumption, they need a built
dist/. Theexportsmap must handle both — or you ship broken tarballs. workspacesglob too broad.["**/*"]matchesnode_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
- https://bun.sh/docs/install/workspaces — Bun workspaces reference.
- https://turbo.build/repo/docs — Turborepo docs.
- https://nx.dev/concepts/mental-model — Nx mental model write-up.
- https://moonrepo.dev — moonrepo (the newer entrant).
- https://github.com/changesets/changesets — Changesets.
- https://pnpm.io/workspaces — pnpm workspaces reference (the format that influenced Bun’s
--filtersyntax). - Vercel’s “Turborepo at Vercel” engineering posts — the canonical case study for large-scale adoption.
- Ch 11 (Component Library) — packaging an internal lib for monorepo consumption.
- Ch 18 (Bun & Deno) — the runtime story underneath the workspace.
- Ch 28 §28.2 (multi-tenancy) — the practical follow-on once you have multiple apps in one repo.
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.