Three years ago, monorepos were a niche strategy associated with Google and Facebook. In 2026, they are the default architecture for any team managing more than two interconnected services. The tooling matured, the pain points got solved, and the alternatives — particularly multi-repo setups — started showing their cracks under the weight of modern development workflows.
I migrated a 14-service SaaS platform from individual repositories to a Turborepo monorepo in late 2025. This article reflects what I learned, what the ecosystem looks like now, and why the monorepo movement is no longer just hype.
The Multi-Repo Problem Nobody Talks About
Multi-repo architecture sounds clean in theory. Each service has its own repository, its own CI/CD pipeline, its own versioning. Independence. Autonomy. Freedom.
In practice, here is what actually happens:
- Dependency drift: Service A uses shared-utils@2.1.0. Service B is stuck on shared-utils@1.8.3 because nobody had time to upgrade. Service C forked the library entirely because the upgrade broke something.
- Cross-cutting changes require N pull requests: Renaming a field in your API schema means opening PRs in 6 repositories, coordinating merges, and hoping nobody deploys out of order.
- Inconsistent tooling: Each repo has a slightly different ESLint config, a slightly different Docker setup, a slightly different CI pipeline. New developers spend their first week just understanding the variations.
- Review fragmentation: Code reviewers lose context because the change is split across repositories. The PR in repo A makes no sense without seeing the corresponding PR in repo B.
The real cost is not any single one of these. It is the compound effect. Every cross-cutting change becomes a project instead of a commit.
What Changed in the Tooling
Monorepos in 2020 required significant custom tooling. You needed Lerna (which was effectively abandoned), custom scripts for build orchestration, and a CI system that could handle selective builds. It was painful enough that many teams tried and retreated.
The 2026 landscape is fundamentally different:
Turborepo
Acquired by Vercel in 2022, Turborepo has become the standard for JavaScript/TypeScript monorepos. Its remote caching alone saves significant CI time. A typical configuration looks like this:
// turbo.json
{
"globalDependencies": ["**/.env.*local"],
"globalEnv": ["NODE_ENV"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
},
"lint": {},
"deploy": {
"dependsOn": ["build", "test", "lint"],
"cache": false
}
}
}
The key innovation is the dependency graph. Turborepo understands that if you change package shared-ui, it needs to rebuild web-app and admin-dashboard but not api-server. This selective execution is what makes monorepo CI viable.
Nx
Nx takes a more opinionated approach. While Turborepo focuses on being a build orchestrator, Nx provides generators, plugins, and an entire development workflow. For larger teams (50+ developers), Nx is often the better choice because it enforces consistency:
# Generate a new library with proper boundaries
npx nx g @nx/js:library shared-validators --directory=libs/shared/validators --tags="scope:shared,type:util"
# Enforce module boundaries in .eslintrc
{
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{ "sourceTag": "scope:web", "onlyDependOnLibsWithTags": ["scope:shared"] },
{ "sourceTag": "scope:api", "onlyDependOnLibsWithTags": ["scope:shared"] }
]
}
]
}
The module boundary enforcement is underrated. It prevents the monorepo from becoming a tangled mess where everything depends on everything.
Bazel and Buck2
For polyglot monorepos (mixing Go, Rust, TypeScript, Python), Bazel remains the gold standard. Buck2, Metas rewrite of Buck, has gained traction in 2025-2026 with better ergonomics. These tools are overkill for most teams but essential when you genuinely need to build across multiple languages with hermetic reproducibility.
The Architecture That Actually Works
After migrating several projects and consulting with teams who have done the same, here is the monorepo structure that consistently works well:
monorepo/
├── apps/
│ ├── web/ # Next.js frontend
│ ├── api/ # Express/Fastify backend
│ ├── admin/ # Admin dashboard
│ └── worker/ # Background job processor
├── packages/
│ ├── config-eslint/ # Shared ESLint configuration
│ ├── config-typescript/ # Shared tsconfig
│ ├── shared-types/ # TypeScript types shared across apps
│ ├── shared-utils/ # Pure utility functions
│ ├── ui/ # Shared UI component library
│ └── database/ # Prisma schema and client
├── tools/
│ ├── scripts/ # Build and deployment scripts
│ └── generators/ # Code generators
├── turbo.json
├── package.json
└── pnpm-workspace.yaml
Critical rules that prevent monorepo entropy:
- Apps never import from other apps. Only from packages.
- Packages declare explicit dependencies. No implicit reliance on hoisted node_modules.
- Shared types are the contract. The
shared-typespackage is the single source of truth for API contracts, database models, and shared interfaces. - One CI pipeline, selective execution. Do not maintain separate CI configs for each app.
Real Performance Numbers
Here are actual measurements from the migration I mentioned earlier:
| Metric | Multi-Repo | Monorepo (Turborepo) |
|---|---|---|
| Full CI run (all services) | 47 min | 12 min (cached) |
| Cross-cutting type change | 6 PRs, ~2 hours | 1 PR, ~20 min |
| New developer onboarding | 3 days | 1 day |
| Dependency update (shared lib) | 6 PRs, 1-2 days | 1 PR, instant |
| CI cost (monthly) | $840 | $310 |
The CI cost reduction comes almost entirely from remote caching. When a build artifact already exists in the cache, Turborepo skips the build entirely. On a team of 12 developers, the cache hit rate stabilized around 78% after the first month.
The Git Performance Question
The most common objection is: “Wont Git become slow with a massive repository?” In 2026, this is largely solved:
- Git sparse checkout lets developers clone only the directories they need. A frontend developer does not need the full history of every backend service.
- Gits built-in filesystem monitor (
fsmonitor) dramatically speeds upgit statuson large repos. - Scalar (from Microsoft, now part of Git itself) enables partial clones and background maintenance.
# Enable sparse checkout for faster clones
git clone --filter=blob:none --sparse https://github.com/org/monorepo.git
cd monorepo
git sparse-checkout set apps/web packages/ui packages/shared-types
# Enable filesystem monitor for faster git status
git config core.fsmonitor true
git config core.untrackedcache true
For repositories under 50GB (which covers the vast majority of monorepos), Git performance is not a practical concern with these settings enabled.
CI/CD in a Monorepo World
The CI strategy is where most monorepo migrations stumble. The naive approach — run everything on every push — defeats the purpose. Here is what works with GitHub Actions:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.filter.outputs.changes }}
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
web:
- 'apps/web/**'
- 'packages/ui/**'
- 'packages/shared-types/**'
api:
- 'apps/api/**'
- 'packages/database/**'
- 'packages/shared-types/**'
build:
needs: changes
runs-on: ubuntu-latest
strategy:
matrix:
package: ${{ fromJSON(needs.changes.outputs.packages) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build test lint --filter=${{ matrix.package }}
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
This combines path-based filtering (only run jobs for changed areas) with Turborepos build caching (skip already-built packages). The result is CI that scales logarithmically with repository size rather than linearly.
When Monorepos Are Wrong
Monorepos are not universally correct. They are a poor fit when:
- Teams have zero shared code. If your services genuinely share nothing — no types, no utilities, no configuration — a monorepo adds complexity without benefit.
- Regulatory boundaries exist. Some compliance frameworks require strict separation of codebases. A monorepo can satisfy this with proper access controls, but it adds overhead.
- Your team is one person. A solo developer with two services does not need build orchestration. Keep it simple.
- Wildly different tech stacks. A React frontend and a Rust backend with no shared artifacts do not benefit from being in the same repository, unless you are using Bazel.
Migration Strategy: The Incremental Approach
Do not migrate everything at once. The approach that works:
- Week 1: Create the monorepo with shared configuration packages (ESLint, TypeScript, Prettier). Move one low-risk service.
- Week 2-3: Extract shared types and utilities into packages. Move a second service that shares code with the first.
- Week 4-6: Migrate remaining services one at a time. Keep the old repos in read-only mode until the team is confident.
- Week 7-8: Set up remote caching, optimize CI, archive old repositories.
The key insight: your monorepo does not need to contain everything on day one. Start with the services that share the most code and expand from there.
Looking Forward
The monorepo trend is not slowing down. Package managers like pnpm have made workspace management trivial. CI providers are adding native monorepo support. Even Rusts Cargo workspaces follow the same pattern.
If you are still managing 5+ repositories that share code, 2026 is the year to consolidate. The tooling is ready, the patterns are proven, and the productivity gains are real. Start with Turborepo if you are in the JavaScript ecosystem, Nx if you need more structure, or Bazel if you are polyglot. The multi-repo tax is no longer worth paying.
