Monorepos
A monorepo is a single version-controlled repository holding many distinct projects — the web app, the API, the mobile app, shared libraries, infrastructure — instead of one repo per project. Google, Meta, and Microsoft famously run colossal ones; more relevantly, the pattern has become standard for product teams whose frontend, backend, and shared packages evolve together.
The core promise is killing cross-repo coordination cost: shared code is imported directly instead of published/versioned/upgraded across repos, and a change to a shared type plus every consumer of it lands in one atomic commit — reviewed together, tested together, deployed together.
TL;DR
- Monorepo = one repo, many projects. Not a monolith — the projects can deploy as independent services; the question is code organization, not runtime architecture.
- The payoff: atomic cross-project changes and shared code without publishing — change a shared type and fix all its consumers in one PR that CI validates as a whole.
- The cost: tooling. Naive monorepos rebuild and retest everything on every commit — you need task caching and affected-detection (Turborepo, Nx, Bazel, Pants).
- In the JS/TS world the standard stack is pnpm workspaces + Turborepo or Nx; polyglot/very-large scale points to Bazel.
- Remote caching is the multiplier: a build any teammate or CI runner already did is a download, not a rebuild.
- Monorepo vs polyrepo is really about coupling: code that changes together belongs together; genuinely independent products with separate teams may not.
The Problem It Solves
Polyrepo sharing, honestly rendered:
Version drift, n× upgrade PRs, and "which version of shared is prod on?" archaeology. The monorepo version:
There is no version to drift — main is the version. That single property is why teams with heavily shared code (design systems, shared API clients, common domain types between a Next.js app and a Node API) gravitate here.
Quick Example
A typical TypeScript monorepo — pnpm workspaces + Turborepo:
dependsOn: ["^build"] means "my dependencies' builds run before mine" — the task graph mirrors the dependency graph, and caching keys on input hashes make unchanged work free.
Core Concepts
Not a Monolith
The perennial confusion: a monorepo is a source layout; a monolith is a deployment unit. A monorepo can contain thirty independently deployed microservices; a monolith can live in a polyrepo. The four combinations all exist — repo strategy and runtime architecture are separate decisions.
Task Graphs, Caching, and Affected Detection
The tooling problem is scale: 40 packages × every commit = unusable CI unless work is skipped safely. Three mechanisms do it:
Remote caching shares the cache across machines: CI builds packages/ui once; every later pipeline and every developer laptop downloads the result. On mature setups this is the difference between 40-minute and 4-minute CI.
The Tooling Landscape
Pragmatic guidance: JS/TS product teams → pnpm + Turborepo (or Nx if you want its structure and rules). Mixed-language at serious scale with a platform team → Bazel-class tools. Don't adopt Bazel for a three-app TypeScript repo.
CI and Deployment in a Monorepo
- PR pipeline =
affectedlint/test/build — not the world. - Deploys are per app, triggered by affected detection or path filters (GitHub Actions
paths:, Vercel/Netlify "ignored build step" hooks). - Trunk-based development fits naturally; long-lived per-project branches fight the model.
- Versioning for published packages (if any) via Changesets — most internal packages simply never get published.
Monorepo vs Polyrepo
The deciding question is coupling: do these projects change together? Frontend + its API + shared types: emphatically yes → monorepo. Two unrelated products with separate teams, compliance boundaries, or open-source components: the isolation of polyrepo may serve better. Many orgs land on several monorepos, one per product area — the pattern is a dial, not a binary.
Common Mistakes
Monorepo Without the Tooling
Plain npm run build scripts looping over packages means every commit rebuilds everything; CI hits 40 minutes; developers revolt and blame the pattern instead of the missing cache. Task-graph tooling is not optional past ~5 packages.
The shared/ Dumping Ground
One packages/utils importing everything and imported by everything makes every change "affected everywhere" — destroying both caching and comprehension. Split shared code by domain (ui, api-client, dates), and use boundary rules (Nx enforce-module-boundaries, ESLint no-restricted-imports) to keep apps from importing each other.
Ignoring Ownership
One repo doesn't mean one team owns everything. CODEOWNERS per directory, required reviews on shared packages, and per-app deploy permissions keep the social structure that polyrepos gave you for free.
Cross-Project Coupling Creep
Because imports are easy, apps quietly reach into each other's internals. The monorepo makes coupling cheap to create and cheap to see — add the lint rules that make the wrong imports fail CI, or you'll rebuild the distributed monolith in source form.
Migrating Everything at Once
The graveyard is full of six-month big-bang consolidations. Start with the highest-coupling cluster (web + api + shared types), prove the workflow, then pull in projects as their coordination pain justifies it.
FAQ
Doesn't Git choke on huge monorepos?
At Google/Meta scale, yes — they run custom VCS layers. At normal-company scale (even thousands of packages), Git is fine, helped by shallow clones, sparse checkout, and partial clone in CI. You'll hit CI-time problems (solved by caching) long before Git problems.
How do open-source projects fit in?
Fine — many of the biggest OSS projects (Babel, Jest, React itself) are monorepos publishing many npm packages, with Changesets/Lerna-style release automation handling versions at the publish boundary.
Can different projects use different dependency versions?
Yes — workspaces resolve per-package package.json, so apps/legacy can hold React 17 while apps/web runs 19. Whether you want that divergence long-term is a policy question; the tooling permits it as a migration state.
What about very different stacks — Go API, TS frontend, Python ML?
Workable, with eyes open: either language-native workspaces side by side with a thin orchestrator (Turborepo runs arbitrary commands), or Bazel/Pants for a unified hermetic graph. The value still hinges on coupling — if the Python ML repo never changes with the frontend, co-locating buys little.
Is Turborepo or Nx "winning"?
Both are healthy defaults with converging features (Nx even supports Turborepo-style minimal adoption). Turborepo optimizes for staying out of your way; Nx for structure, codegen, and enforced architecture. Choose by how much framework you want around the task runner — migration between them is not traumatic.
Related Topics
- Microservices — The runtime-architecture axis this is not
- CI/CD — Where affected-detection and caching pay off
- Build Tools — The bundlers the task graph orchestrates
- Version Control — Trunk-based flow underneath
- GitHub Actions — Path filters and matrix builds per app
- TypeScript — Project references and shared types across packages