Monorepo vs Polyrepo: Nx, Turborepo, and the Real Tradeoffs
Two years ago, I migrated BirJob's codebase from a polyrepo setup (separate repos for the frontend, the scraper, the API, and shared utilities) to a monorepo. Six months later, I migrated back. Then, a year after that, I moved to a monorepo again — this time with Turborepo and a much better understanding of what I was doing.
The monorepo vs polyrepo debate is one of the most polarizing topics in software engineering. Google, Meta, and Twitter famously use monorepos. Netflix, Amazon, and Spotify use polyrepos. Both camps have billion-dollar companies and brilliant engineers behind them. The answer isn't "monorepo is better" or "polyrepo is better" — it's "which tradeoffs are you willing to accept?"
This guide cuts through the ideology and focuses on the practical reality: what each approach actually costs, what tools make each viable, and how to make the right choice for your team.
Part 1: Defining the Terms
Monorepo
A single repository that contains multiple projects, packages, or services. The key distinction: a monorepo is not a monolith. Each project within the monorepo can be independently deployed, independently versioned, and owned by different teams.
birjob/
├── apps/
│ ├── web/ # Next.js frontend
│ ├── api/ # Express API
│ └── scraper/ # Python scraper
├── packages/
│ ├── ui/ # Shared React components
│ ├── db/ # Prisma schema + client
│ ├── config/ # Shared ESLint, TypeScript configs
│ └── utils/ # Shared utility functions
├── turbo.json
├── package.json
└── pnpm-workspace.yaml
Polyrepo
Each project or service lives in its own Git repository, with its own CI/CD pipeline, its own dependency management, and its own versioning.
GitHub:
birjob/web # Next.js frontend
birjob/api # Express API
birjob/scraper # Python scraper
birjob/ui-lib # Published to npm as @birjob/ui
birjob/db-client # Published to npm as @birjob/db
birjob/utils # Published to npm as @birjob/utils
Part 2: The Real Tradeoffs
Most articles list generic pros and cons. Let me share the specific, concrete experiences that surprised me.
What Monorepos Actually Give You
1. Atomic cross-project changes. This is the killer feature. In a polyrepo, if you need to change a shared API interface and update all consumers, you need to: (a) change the shared library, (b) publish a new version, (c) update the dependency in every consumer repo, (d) test and deploy each one. In a monorepo, it's one PR that changes the interface and all consumers together.
According to Google's research paper on their monorepo, this atomic change capability is the primary reason they maintain a single repository for billions of lines of code.
2. Code sharing without publishing. In a polyrepo, sharing code between projects means publishing npm packages (or pip packages, or whatever). This requires a package registry, versioning strategy, and the overhead of "publishing" every change. In a monorepo, you import directly.
// Monorepo: direct import (immediate, no publishing)
import { Button } from '@birjob/ui';
import { prisma } from '@birjob/db';
// Polyrepo: published package (requires npm publish + version bump)
import { Button } from '@birjob/ui'; // version 2.3.1 from npm
import { prisma } from '@birjob/db'; // version 1.8.0 from npm
3. Consistent tooling. One ESLint config, one TypeScript config, one CI pipeline template. When you update a linting rule, it applies everywhere at once.
4. Easier refactoring. Rename a function that's used across three projects? In a monorepo, your IDE's "rename symbol" works across all of them. In a polyrepo, you're doing manual find-and-replace across repos.
What Monorepos Actually Cost You
1. CI/CD complexity. When someone pushes to the monorepo, which projects need to be built and tested? You need a system to detect affected projects and only run relevant pipelines. Without this, CI takes forever. This is where tools like Nx and Turborepo earn their keep.
2. Git performance at scale. A large monorepo with thousands of files and years of history can make git status, git log, and git clone slow. GitHub's blog on Git performance discusses using sparse checkouts and the filesystem monitor to mitigate this. For most teams under 50 developers, this isn't a real problem yet.
3. Permission boundaries. In a polyrepo, access control is simple: you either have access to a repo or you don't. In a monorepo, giving the contractor access to the frontend without letting them see the billing code requires CODEOWNERS files and careful branch protection rules.
4. Dependency conflicts. If Project A needs React 18 and Project B needs React 17, a monorepo forces you to resolve this. In a polyrepo, they can happily coexist. This sounds minor but can be a real blocker for gradual migrations.
Comparison Table
| Factor | Monorepo | Polyrepo |
|---|---|---|
| Cross-project changes | One atomic PR | Multiple PRs, coordinated releases |
| Code sharing | Direct imports | Published packages |
| CI/CD setup | Complex (need affected detection) | Simple (one repo = one pipeline) |
| Onboarding | One clone, everything's there | Need to find and clone multiple repos |
| Git performance | Degrades at scale | Always fast per-repo |
| Access control | Granular (CODEOWNERS) | Simple (repo-level) |
| Dependency versions | Unified (can conflict) | Independent per repo |
| Tooling consistency | Enforced globally | Per-repo (can drift) |
| Team autonomy | Shared standards | Full independence |
| Discoverability | Everything searchable in one place | Need to know which repo to look in |
Part 3: Turborepo
Turborepo (now part of Vercel) is a build system for JavaScript/TypeScript monorepos. Its core innovation is content-addressable caching: it hashes the inputs to each task (source files, dependencies, environment variables) and caches the output. If the inputs haven't changed, the task is skipped entirely.
Setup
# Initialize a Turborepo
npx create-turbo@latest birjob-monorepo
# Or add to existing monorepo
npm install turbo --save-dev
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"], // Build dependencies first
"outputs": [".next/**", "dist/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
How Caching Works
# First run: builds everything
$ turbo run build
┌ @birjob/ui:build (1.2s)
├ @birjob/db:build (0.8s)
├ @birjob/web:build (12.4s)
└ @birjob/api:build (3.1s)
Total: 17.5s
# Second run (nothing changed): cache hits
$ turbo run build
┌ @birjob/ui:build (cache hit, replaying output)
├ @birjob/db:build (cache hit, replaying output)
├ @birjob/web:build (cache hit, replaying output)
└ @birjob/api:build (cache hit, replaying output)
Total: 0.3s
# After changing a file in @birjob/ui:
$ turbo run build
┌ @birjob/ui:build (1.2s) ← rebuilt
├ @birjob/db:build (cache hit)
├ @birjob/web:build (12.4s) ← rebuilt (depends on ui)
└ @birjob/api:build (cache hit) ← not affected
Total: 13.6s
Remote Caching
Turborepo's remote cache (via Vercel or self-hosted) shares the cache across all team members and CI. When a teammate builds the same code, they get a cache hit even though it was built on someone else's machine.
# Enable remote caching with Vercel
npx turbo login
npx turbo link
# Now cache is shared across team + CI
# CI builds are dramatically faster
Turborepo Strengths and Limitations
Strengths: Fast, simple configuration, excellent Vercel integration, remote caching out of the box.
Limitations: JavaScript/TypeScript only, less powerful than Nx for code generation and analysis, no built-in dependency graph visualization.
Part 4: Nx
Nx is the more full-featured monorepo tool. It supports JavaScript/TypeScript, but also Go, Rust, Python, Java, and more. It includes code generators, dependency graph visualization, and sophisticated affected detection.
Setup
# Create new Nx workspace
npx create-nx-workspace@latest birjob --preset=ts
# Add Nx to existing monorepo
npx nx@latest init
Project Configuration
// nx.json
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production"],
"cache": true
},
"lint": {
"inputs": ["default", "{workspaceRoot}/.eslintrc.json"],
"cache": true
}
},
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": ["default", "!{projectRoot}/**/*.spec.ts"],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
}
}
// apps/web/project.json
{
"name": "web",
"targets": {
"build": {
"executor": "@nx/next:build",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/web"
}
},
"serve": {
"executor": "@nx/next:server",
"options": {
"buildTarget": "web:build",
"dev": true
}
}
}
}
Nx Unique Features
# Dependency graph visualization (opens in browser)
nx graph
# Run only affected targets
nx affected -t build
nx affected -t test
nx affected -t lint
# Code generation
nx generate @nx/react:component Button --project=ui
nx generate @nx/node:library utils --directory=packages
# Module boundary enforcement
// .eslintrc.json (root)
{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{ "sourceTag": "scope:web", "onlyDependOnLibsWithTags": ["scope:shared", "scope:web"] },
{ "sourceTag": "scope:api", "onlyDependOnLibsWithTags": ["scope:shared", "scope:api"] }
]
}
]
}
}
Nx vs Turborepo Comparison
| Feature | Nx | Turborepo |
|---|---|---|
| Language support | Multi-language (JS, Go, Rust, Python, Java) | JavaScript/TypeScript only |
| Caching | Local + remote (Nx Cloud) | Local + remote (Vercel) |
| Code generation | Built-in generators + plugins | Not included |
| Dependency graph | Visual, interactive | Basic (turbo run --graph) |
| Module boundaries | ESLint rule enforcement | Not included |
| Affected detection | Fine-grained, customizable inputs | Via --filter flag |
| Configuration | More complex (project.json per app) | Simpler (turbo.json only) |
| Learning curve | Steeper | Gentler |
| Community | Large, enterprise-focused | Growing, Vercel ecosystem |
| Pricing (cloud) | Free tier + paid | Free tier + paid (Vercel) |
Part 5: Other Monorepo Tools
pnpm Workspaces
pnpm workspaces provide the foundation that both Nx and Turborepo build on. You can use pnpm workspaces alone for simpler monorepos without a build orchestrator.
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
# Install all dependencies
pnpm install
# Run a script in a specific package
pnpm --filter @birjob/web dev
# Run a script in all packages
pnpm -r build
Lerna (Legacy)
Lerna was the original JavaScript monorepo tool, now maintained by Nx. It's still used for publishing npm packages from a monorepo, but Nx or Turborepo are better choices for build orchestration.
Bazel
Bazel (by Google) is the heavyweight option. Extremely powerful, supports any language, hermetic builds, remote execution. But it has a steep learning curve and is overkill for teams under 50 engineers. The Bazel documentation positions it for large-scale, multi-language codebases.
Part 6: Migration Strategy
Polyrepo to Monorepo
# Step 1: Create the monorepo structure
mkdir birjob-mono && cd birjob-mono
git init
mkdir -p apps packages
# Step 2: Import repos with history
git remote add web-origin https://github.com/birjob/web.git
git fetch web-origin
git merge web-origin/main --allow-unrelated-histories --no-edit
git mv $(ls -A | grep -v apps | grep -v packages | grep -v .git) apps/web/
# Repeat for each repo...
# Step 3: Set up workspace
pnpm init
# Add pnpm-workspace.yaml
# Add turbo.json or nx.json
# Step 4: Update imports
# Change "import from '@birjob/ui'" to use workspace packages
# Remove published package versions, use "workspace:*"
# Step 5: Unified CI/CD
# Create single pipeline with affected detection
Monorepo to Polyrepo
# Use git filter-branch or git-filter-repo to extract
# a directory with its full history
pip install git-filter-repo
# Extract apps/web into its own repo
git clone birjob-mono birjob-web
cd birjob-web
git filter-repo --subdirectory-filter apps/web
# Set up independent CI/CD
# Publish shared packages to npm registry
# Update imports to use published packages
Part 7: My Opinionated Take
After living with both approaches and failing at both, here's what I believe:
1. Monorepos are better for small-to-medium teams (2-20 developers). The overhead of managing multiple repos, keeping them in sync, and publishing shared packages is not worth it when everyone works on everything. A monorepo with Turborepo takes 30 minutes to set up and pays for itself in the first week.
2. Polyrepos are better for autonomous teams. If your organization has distinct teams that own distinct services and rarely change shared code together, polyrepos give them the independence they need. The coordination overhead of a monorepo adds friction without corresponding benefit.
3. The tool matters less than the discipline. I've seen monorepos descend into chaos (everyone committing to everything, no ownership, 30-minute CI) and polyrepos work beautifully (well-defined interfaces, automated dependency updates, clear ownership). The organizational discipline matters more than the repository structure.
4. Start with a monorepo. Split when you feel the pain. It's easier to extract a project from a monorepo into its own repo than to merge multiple repos into a monorepo. Start unified, and split only when you have a concrete reason (team autonomy, language mismatch, access control).
5. Turborepo for simplicity, Nx for power. If you're a JavaScript/TypeScript shop and want minimal configuration, Turborepo is the right choice. If you need multi-language support, code generation, module boundaries, or you're a larger team, Nx is worth the additional complexity.
Part 8: Action Plan
Week 1: Evaluate
- List all your repositories and their dependencies on each other
- Count how often you make cross-repo changes (PRs that require changes in multiple repos)
- Identify shared code that's duplicated across repos
- Ask the team: what's the biggest pain point with the current setup?
Week 2: Prototype
- Set up a monorepo with Turborepo or Nx (use a branch, not production)
- Move 2-3 small projects into it
- Set up CI with affected detection
- Have the team try it for a week
Week 3-4: Decide and Execute
- Gather feedback from the prototype
- Decide: monorepo, polyrepo, or hybrid
- If monorepo: migrate remaining projects incrementally
- If polyrepo: improve tooling (automated dependency updates, shared CI templates)
Sources
- Google: Why Google Stores Billions of Lines of Code in a Single Repository
- Turborepo Documentation
- Nx Documentation
- GitHub Blog: Improving Git Monorepo Performance
- pnpm Workspaces
- Bazel Build System
I'm Ismat, and I build BirJob — Azerbaijan's job aggregator scraping 80+ sources daily.
