Monorepo vs Polyrepo: Making the Right Choice
A monorepo stores multiple projects in a single repository. Companies like Google, Facebook, and Airbnb use monorepos for their codebases. But that doesn't mean they're right for every team.
Choose a monorepo when:
- You have shared libraries used by multiple apps
- Your teams frequently make cross-project changes
- You want unified tooling, linting, and testing
- You're building a design system or component library
Stick with polyrepos when:
- Projects have completely different tech stacks
- Teams work in complete isolation
- Security/compliance requires strict separation
pnpm Workspaces: The Foundation
Most modern monorepo tools build on package manager workspaces:
# pnpm-workspace.yaml (root of monorepo)
packages:
- 'apps/*' # app-web, app-mobile, app-api
- 'packages/*' # ui, utils, config, types
// Root package.json
{
"name": "my-monorepo",
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"test": "turbo test"
},
"devDependencies": {
"turbo": "^2.0.0",
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
}
}
# Install dependencies for all workspaces
pnpm install
# Add dependency to specific package
pnpm add react --filter @myapp/web
# Add shared internal package
pnpm add @myapp/ui --filter @myapp/web
Turborepo: Fast Cached Builds
Turborepo is the simplest and most popular monorepo build system:
// turbo.json (root)
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"], // Build dependencies first
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"lint": {
"outputs": []
},
"dev": {
"cache": false, // Never cache dev servers
"persistent": true // Long-running process
}
}
}
# Run build for all packages (cached after first run)
turbo build
# Run only for changed packages
turbo build --filter=[HEAD^1]
# Run for a specific app and its dependencies
turbo build --filter=@myapp/web...
# Clear cache
turbo build --force
Turborepo Remote Caching
# Connect to Vercel Remote Cache
npx turbo login
npx turbo link
# Now CI machines share the same cache — 0s builds for unchanged code!
# .github/workflows/ci.yml
- name: Build
run: turbo build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
Nx: More Powerful, More Complex
Nx is better for large codebases with complex dependency graphs:
# Create new Nx monorepo
npx create-nx-workspace@latest myorg
# Generate apps and libraries
nx generate @nx/react:app web
nx generate @nx/node:app api
nx generate @nx/react:library ui
nx generate @nx/js:library utils
# Run affected tasks (only what changed since main branch)
nx affected --target=build
nx affected --target=test --base=origin/main
# Dependency graph visualization
nx graph
// nx.json
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"cache": true
},
"test": {
"cache": true,
"inputs": ["default", "^production"]
}
},
"namedInputs": {
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/jest.config.ts"
]
}
}
Shared Package Structure
monorepo/
├── apps/
│ ├── web/ # Next.js app
│ │ └── package.json # depends on @myapp/ui, @myapp/utils
│ └── api/ # Express API
│ └── package.json # depends on @myapp/utils, @myapp/types
└── packages/
├── ui/ # Shared React components
│ ├── src/
│ │ ├── Button.tsx
│ │ └── index.ts
│ ├── package.json
│ └── tsconfig.json
├── utils/ # Shared utilities
├── types/ # Shared TypeScript types
└── config/ # Shared tsconfig, eslint config
// packages/ui/package.json
{
"name": "@myapp/ui",
"version": "0.0.0",
"main": "./src/index.ts", // Source directly — no build step needed in dev
"types": "./src/index.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc --build",
"dev": "tsc --watch"
},
"peerDependencies": {
"react": "^18.0.0"
}
}
// apps/web/package.json
{
"name": "@myapp/web",
"dependencies": {
"@myapp/ui": "workspace:*", // pnpm workspace reference
"@myapp/utils": "workspace:*"
}
}
Shared TypeScript Config
// packages/config/tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
// apps/web/tsconfig.json
{
"extends": "@myapp/config/tsconfig.base.json",
"compilerOptions": {
"lib": ["dom", "ES2022"],
"jsx": "react-jsx"
},
"include": ["src"]
}
Shared ESLint Config
// packages/config/eslint.base.js
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
rules: {
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
},
};
// apps/web/.eslintrc.js
const base = require('@myapp/config/eslint.base.js');
module.exports = {
...base,
extends: [...base.extends, 'plugin:react/recommended'],
};
Turborepo vs Nx Comparison
| Feature | Turborepo | Nx |
|---|---|---|
| Setup complexity | Simple | Complex |
| Learning curve | Low | High |
| Caching | ✅ Excellent | ✅ Excellent |
| Incremental builds | ✅ | ✅ |
| Code generation | ❌ Basic | ✅ Rich |
| Plugin ecosystem | Growing | Mature |
| Remote caching | Vercel (free) | Nx Cloud (paid/self-host) |
| Best for | Small-medium | Large enterprise |
→ Convert package configs between JSON and YAML with the JSON to YAML Converter.