Every monorepo has two eras. The first is the honeymoon: one clone, one install, one PR that changes the API client and every app that consumes it, atomically. The second era starts about eight months in, when CI takes twenty-five minutes and someone asks why changing a README rebuilt fourteen packages. Most monorepo advice is written from inside the honeymoon. This post is written from the other side.
We run client products and our own tooling out of monorepos at Luminary, and we've inherited a few that were actively on fire. The pattern that keeps working — small senior team, multiple deployable apps, a growing pile of shared packages — is pnpm workspaces at the bottom, Turborepo for orchestration, affected-only CI on top, and Changesets only where something actually gets published. None of it is exotic. All of it fails in specific, predictable ways if you skip a step, and the failure modes are the interesting part.

Why monorepos won — and what they actually cost
For product teams, the argument ended on three points:
Atomic cross-package changes. The API contract, the client SDK, and the three apps that consume it change in one PR, reviewed and merged together. In a polyrepo, that's four PRs, a dependency-bump dance, and a window where production runs a combination of versions nobody ever tested together. That window is where the weird bugs live.
One toolchain. One ESLint config, one TypeScript version, one CI definition. Upgrading Node is a single PR instead of a quarter-long migration tracked in a spreadsheet.
Shared code with zero publishing ceremony. @repo/ui, @repo/config, @repo/analytics — imported directly, no registry, no version negotiation, no "which version of the design system is checkout on?"
Now the bill, which arrives later:
- CI time scales with the repo, not the change — unless you do deliberate work to prevent it. The naive setup ("run everything on every PR") is fine at 5 packages and unbearable at 40.
- Tooling becomes infrastructure. Someone now owns the task graph, the cache config, and the CI topology. That complexity exists in polyrepos too, but it's smeared across repos where nobody sees it accumulate.
- Blast radius. A bad change to a shared package breaks everything at once. That's also the point — you find out at PR time instead of three weeks later — but it changes how you review shared code, and a broken
mainblocks everyone.
The honest framing: a monorepo doesn't remove coordination cost, it moves it from release time (version bumps, integration surprises) to build time (CI, caching, boundaries). The trade is worth it only if you invest in the build-time side. Everything below is that investment.
When not to monorepo
Skipping this question is how you end up writing the "we split our monorepo" post two years from now.
- Genuinely separate products with separate teams. If two codebases share no code, no deploy cadence, and no reviewers, colocating them buys nothing and couples their CI, tooling upgrades, and broken-main incidents. A monorepo per product line is a perfectly good architecture.
- Open-source libraries with independent release cadences and outside contributors. Contributors want to clone one library, not your company. Issues, CI permissions, and release automation all get harder inside a private-ish monorepo. (Monorepos of related OSS packages — a plugin ecosystem — are the exception; that's what Changesets was built for.)
- Hard access-control boundaries. Git doesn't do per-directory read permissions. If team A must not read team B's code, a monorepo fights you constantly.
- Wildly heterogeneous stacks. A Rust service, an ML pipeline, and a Next.js app can share a repo, but the JS-ecosystem tooling here won't orchestrate the non-JS parts. At that scale you're shopping for Bazel or Pants — a different article and a different life.
For a product team shipping several TypeScript apps plus shared packages, though, the default is a monorepo — the case the rest of this post assumes.
The foundation: pnpm workspaces
Why pnpm specifically
Two properties make pnpm the right base layer, and only one is the famous one: disk and install efficiency, via a global content-addressable store with node_modules built from hard links. Ten workspace packages depending on the same version of react share one copy on disk; installs are fast and CI caches are small.
The one that actually saves you from bugs is strict node_modules. npm and classic Yarn hoist everything flat, so any package can import any dependency installed anywhere in the tree — including transitive dependencies it never declared. That's a phantom dependency, and in a monorepo it's poison: @repo/ui silently depends on date-fns because the marketing app hoisted it, and everything works until the marketing app drops date-fns and an unrelated package breaks. pnpm's symlinked layout makes undeclared imports fail immediately, at development time, on the machine of the person who wrote them. That's the difference between a dependency graph you declared and one you archaeology.
The workspace definition is one file at the repo root:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
catalog:
react: ^19.1.0
react-dom: ^19.1.0
typescript: ^5.8.0
zod: ^3.24.0
(More on that catalog block in the dependency-management section.)
The workspace protocol
Internal dependencies use the workspace protocol:
{
"dependencies": {
"@repo/ui": "workspace:*"
}
}
Locally, the dependency always resolves to the workspace copy — never to a stale published version from the registry, a classic source of "works on my machine, broken in CI." At publish time, pnpm rewrites workspace:* to a real semver range, so published packages stay consumable by the outside world.
Internal packages: export source, or build?
Every shared package faces one decision: export TypeScript source, or compiled output?
Source exports (sometimes called just-in-time packages): the package's exports field points straight at ./src/index.ts, and the consuming app's bundler — Next.js, Vite, whatever — compiles it as part of its own build. No build step, no watch mode, no dist folder; changes reflect instantly. This is our default for app-internal packages.
Built packages: the package compiles to dist/ with its own tsconfig, ships declaration files, and consumers see it exactly as an npm package. You need this when the package is actually published, when consumers aren't bundlers (a Node script importing it directly), or when it's large and stable enough that building once and caching beats recompiling it inside every app build.
The trade-off is real: source exports push compilation cost into every consumer and surface the package's type errors in the consumer's typecheck; built packages reintroduce the build-orchestration problem you came here to avoid. Start with source exports; promote when published, hot, or huge.
Task orchestration: Turborepo vs Nx vs plain scripts
Past a handful of packages, pnpm -r run build stops being an answer: it doesn't know that @repo/ui must build before apps/web, and it re-runs everything whether or not anything changed. You need a runner that understands the dependency graph and caches by input hash.
Plain pnpm scripts are fine below roughly five packages, especially if everything is source-exported and there's nothing to build but the apps. pnpm --filter web... build (the ... includes dependencies) gets you surprisingly far, with no cache, no graph config, no extra tool. Know that this is a stage, not a destination.
Turborepo is a thin layer that does three things well: builds a task graph from your existing package.json scripts, caches task outputs keyed by a hash of their inputs, and shares that cache remotely. Configuration is one file. Since Turborepo 2.0 the top-level key is tasks (older tutorials say pipeline — mentally rename as you read):
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env.example"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"lint": {},
"test": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", "vitest.config.ts"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
"dependsOn": ["^build"] reads as "before building this package, build its workspace dependencies" — that caret is most of the mental model. outputs tells Turborepo what to save and restore; forget it and your cache "works" but restores nothing.
Nx is the more powerful, more opinionated option: computation caching like Turborepo, plus code generators, plugin-managed tooling, module-boundary enforcement, and CI-level features like distributed task execution and flaky-test retries via Nx Cloud. For a large monorepo with many teams, Nx's ceiling is genuinely higher. The costs are a bigger conceptual surface and a squishier remote-cache story: Nx's self-hosted cache options have gone from community-built to paid (Powerpack) to free official plugins to deprecation of those plugins after a cache-poisoning class of vulnerability (CVE-2025-36852) — the supported paths now are Nx Cloud or a self-hosted server implementing their remote-cache OpenAPI spec. Not disqualifying, but it's churn in a load-bearing component. Turborepo's remote cache, by contrast, is a simple HTTP API: Vercel-hosted by default, several stable self-hosted open-source implementations, and cache-entry signing (signature: true) if you want tamper evidence.
| Plain pnpm | Turborepo | Nx | |
|---|---|---|---|
| Task graph | Manual (--filter foo...) | From package.json scripts | From project graph + plugins |
| Local caching | None | Input-hash based | Input-hash based |
| Remote caching | None | Vercel or self-hosted (open API) | Nx Cloud or OpenAPI self-hosted |
| Affected detection | Manual git filters | --affected / git-range filters | nx affected |
| Generators / scaffolding | No | Minimal (turbo gen) | Extensive |
| Boundary enforcement | No | Basic (experimental) | Mature (enforce-module-boundaries) |
| Conceptual overhead | None | Low | Moderate–high |
Our pick for studio-scale teams is Turborepo: 90% of the value with 10% of the surface area, and it doesn't mind being removed later. Choose Nx when you have many teams, want generators enforcing consistency, or need distributed task execution. Choose plain pnpm when you can still name every package from memory.
TypeScript in a monorepo
The tempting shortcut is one giant tsconfig.json with paths mapping every package to its source. It works at first, then degrades into a monolithic typecheck that gets slower forever. The structure that holds up:
- A
tsconfig.base.jsonat the root with compiler options only —strict,moduleResolution: "bundler", target, lib. Nopathsto package internals. - Each package has its own
tsconfig.jsonextending the base. Cross-package imports resolve throughpackage.jsonexportslike real dependencies, not path aliases — keeping every package honest about its public surface and making eventual extraction trivial. - For built packages, use project references (
composite: trueplus areferencesarray) andtsc --buildfor incremental, dependency-ordered compilation with.tsbuildinfocaching. For source-exported packages, skip references — runtsc --noEmitper package as atypechecktask and let Turborepo cache and order it. References buy the most when there's actual emit to orchestrate.
One habit that pays for itself: import type for type-only cross-package imports. It documents intent, keeps runtime dependency edges honest, and with verbatimModuleSyntax the compiler enforces that types never smuggle runtime code across a boundary you thought was type-only.
CI that scales with the change, not the repo
This is where monorepos melt or don't. The design goal: a PR's CI cost should be proportional to what the PR touched.
Affected-only, via the task graph
Path-based filtering (GitHub Actions paths:) is the obvious move and the wrong primitive, because paths don't know your dependency graph. A change to packages/ui must test apps/web too, and hand-encoding that mapping in YAML means it's wrong within a month. Let the tool that owns the graph decide:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # enough history to diff against the merge base
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build typecheck lint test --affected
--affected diffs against the base branch and runs tasks only in changed packages and everything that depends on them; the explicit form is --filter="...[origin/main]" if you need custom ranges. Reserve paths: filters for genuinely disjoint worlds — skipping the whole JS pipeline when only infra/ changed — not for intra-workspace routing.
Remote cache in CI
Affected-detection prunes the graph; remote caching makes what's left cheap. With TURBO_TOKEN/TURBO_TEAM set, every task result is keyed by input hash and shared: the PR builds @repo/ui, the merge-to-main run gets a cache hit, your laptop gets the same hit after pulling. This is routinely the biggest CI-time win in the whole setup, and it costs one config block and two secrets.
The flaky-cache debugging story
Everyone gets one of these; here's the shape of ours so yours is shorter. A Next.js app in a client monorepo intermittently shipped a build where an environment-driven flag was wrong — never reproducibly. The cause: the build read process.env.FLAG, but the var wasn't declared in the task's env list in turbo.json. Two builds with different env values produced the same input hash, and CI happily restored a cached artifact built under the other value. The cache wasn't flaky. It was doing exactly what we told it: we had lied about the inputs.
The lessons generalize:
- Every input must be in the hash. Env vars a task reads go in
env(orpassThroughEnvif they shouldn't affect the hash). Files outside the package that affect output go ininputs. - When output looks stale, run with
--summarizeor--dry=jsonand diff the hash inputs of the good and bad runs. Turborepo will tell you exactly what it hashed; the discrepancy is your bug. turbo run build --forceis the tourniquet, not the fix. If--force"solves" it, you have an undeclared input. Find it.
Versioning and releasing
The clean mental split: apps deploy, libraries release. Don't version things nobody installs.
Apps — the Next.js sites, the API services — have no meaningful version. They deploy from main (or whatever branch flow you run; ours is feature → dev → prod) via the platform's Git integration. Adding semver to an app is ceremony with no consumer.
Published libraries are where Changesets earns its keep. Contributors add a small markdown "changeset" alongside their PR declaring the bump and the human-readable change; a bot PR accumulates them; merging it versions the packages, writes changelogs, and publishes. Internal workspace:* dependents bump automatically per updateInternalDependencies.
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": ["@changesets/changelog-github", { "repo": "your-org/your-repo" }],
"commit": false,
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"fixed": [],
"linked": [],
"ignore": ["web", "docs"]
}
The versioning-mode decision: independent (the default — each package versions on its own) is right when packages are genuinely usable alone. Fixed groups always share one version number — right for framework-style suites where "which versions work together" is a support question you never want to answer. linked is the middle ground: versions move together when packages release together, without empty bumps for untouched ones. Default to independent; reach for fixed only when your packages are consumed as a set.
And the ignore list is the punchline: the apps are in it. They deploy; they don't release.
Dependency management: one lockfile, aligned versions
A single lockfile is quietly one of the monorepo's biggest wins, including for supply-chain security: exactly one place declares every resolved dependency in your product surface. One file to audit, one file for Dependabot/Renovate to watch, one place where a malicious postinstall would have to land. Ten repos means ten lockfiles drifting at ten different rates, and nobody diffs ten lockfiles.
The remaining problem is internal drift: three apps declaring three different ranges of zod, which pnpm dutifully installs as three copies — bloating installs and splitting types (instanceof failures across duplicated packages are a rite of passage). Two tools:
- pnpm catalogs (the
catalog:block inpnpm-workspace.yamlabove): define the version once, and packages declare"zod": "catalog:". Upgrades become a one-line diff at the root. Our default for anything used by more than one package. - syncpack for enforcement:
syncpack lintin CI fails when versions drift or someone bypasses the catalog. Catalogs make alignment easy; syncpack makes misalignment loud.
Ownership and boundaries
A monorepo removes the physical boundaries between codebases, so reintroduce the ones you actually wanted as policy:
- CODEOWNERS maps directories to reviewers:
/packages/design-system/requires the design-system owners,/packages/payments/requires whoever answers pages for payments. Shared packages deserve more review friction than app code, not less — their blast radius is the whole repo. - Lint rules against reach-ins. The
exportsfield in eachpackage.jsonalready blocks deep imports at resolve time —@repo/ui/src/internal/useFocusTrapshouldn't resolve at all. Back it withno-restricted-imports(or Nx'senforce-module-boundaries, the most mature tool here) so violations fail in the editor, not in a release. The rule to enforce ruthlessly: packages import each other only through their public entry points. Every reach-in is a future refactor someone can't do. - Ship shared config as packages too —
@repo/eslint-config,@repo/tsconfig— so "one toolchain" is an importable artifact rather than a copy-paste convention that drifts.
Getting in, and getting out
Migrating from polyrepo: don't big-bang it. Stand up the workspace skeleton (pnpm-workspace.yaml, turbo.json, base tsconfig, CI) around your most central repo first. Import each subsequent repo with git subtree add (or git filter-repo to graft full history into a subdirectory), fix its imports to workspace:*, delete its now-redundant config, and archive the old repo the same day — a half-migrated state where both copies accept commits is the worst of all worlds. Migrate the app with the most shared-code pain first. Expect the tail (CI credentials, deploy wiring, the one repo with a weird build) to take longer than the head.
Knowing when to split: the signals are organizational, not technical. A team that never touches the rest of the repo yet pays its costs; release cadences fighting each other; a compliance boundary policy can't satisfy. When a subtree has no workspace:* edges in or out and a disjoint set of reviewers, it's already a separate project — the repo boundary is just catching up. Extraction is mechanical if you enforced boundaries all along, the best argument for enforcing them from day one.
Takeaways
- A monorepo moves coordination cost from release time to build time. Budget for the build-time side — caching, affected-detection, boundaries — or don't do it.
- Don't monorepo across separate products, hard access-control boundaries, or OSS libraries with independent cadences and outside contributors.
- pnpm's strict
node_modulesis the load-bearing feature, not the disk savings: phantom dependencies die at dev time. Useworkspace:*for all internal deps. - Default internal packages to source exports; promote to built only when published, consumed outside bundlers, or hot enough that caching their build pays.
- Turborepo covers most teams with minimal surface; Nx has a higher ceiling at higher conceptual cost; plain pnpm filters are fine until roughly five packages.
- CI cost must be proportional to the change:
turbo --affectedfor graph-aware pruning, remote cache for what survives pruning,paths:filters only for disjoint worlds like infra. - Cache "flakiness" is almost always an undeclared input — usually an env var missing from
envinturbo.json. Debug with--summarize; treat--forceas a symptom. - Apps deploy, libraries release. Changesets with independent versioning by default;
fixedonly for packages consumed as a suite; put the apps inignore. - One lockfile is a supply-chain asset. Kill internal version drift with pnpm catalogs; enforce with syncpack in CI.
- Enforce boundaries from day one — CODEOWNERS on shared packages, public-entry-point imports only. It keeps blast radius reviewable and makes the eventual split, if it comes, mechanical.