Monorepo Guide — Turborepo, Nx, and pnpm Workspaces
Set up a monorepo with Turborepo, Nx, or pnpm workspaces. Task orchestration, dependency management, CI optimization, and when a monorepo actually makes sense.
A monorepo is not "putting all your code in one repository." That's just a big repo. A monorepo is a repository containing multiple distinct projects that share tooling, dependencies, and build infrastructure. Google, Meta, Microsoft, and Uber all use monorepos — but they also have dedicated teams maintaining their monorepo tooling.
For the rest of us, the question is whether the benefits outweigh the tooling overhead. This guide covers when monorepos make sense, how to set one up with three popular tools, and the pitfalls that make teams regret the decision.
Should You Use a Monorepo?
| Monorepo makes sense when... | Polyrepo is better when... |
|---|---|
| Multiple packages share types/interfaces | Teams are independent with different deploy cycles |
| You want atomic commits across packages | Packages have wildly different tech stacks |
| Shared component library + multiple apps | You don't want to maintain monorepo tooling |
| Team is small to medium (2-30 devs) | Hundreds of developers (without dedicated infra team) |
| Frontend + backend + shared types | Open source libraries with independent versioning |
The biggest cost: tooling complexity. You need task orchestration, smart caching, and CI that doesn't rebuild everything on every commit.
Tool Comparison
| Feature | pnpm Workspaces | Turborepo | Nx |
|---|---|---|---|
| What it is | Package manager | Build orchestrator | Full build system |
| Task running | Basic (--filter) | Parallel, cached | Parallel, cached, distributed |
| Remote cache | No | Yes (Vercel) | Yes (Nx Cloud) |
| Dependency graph | Implicit | Explicit (turbo.json) | Auto-detected |
| Code generation | No | No | Yes (generators) |
| Learning curve | Low | Low-Medium | Medium-High |
| Best for | Simple monorepos | Frontend-heavy monorepos | Large, complex monorepos |
Option 1: pnpm Workspaces (Foundation)
pnpm workspaces is the lightest approach. No extra tool, just your package manager.
Setup
# Install pnpm globally
npm install -g pnpm
# Initialize the monorepo
mkdir my-monorepo && cd my-monorepo
pnpm init
Create the workspace configuration:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
// package.json (root)
{
"name": "my-monorepo",
"private": true,
"scripts": {
"dev": "pnpm --filter './apps/*' dev",
"build": "pnpm --filter './apps/*' build",
"lint": "pnpm -r lint",
"test": "pnpm -r test"
}
}
Creating Packages
mkdir -p packages/shared apps/web apps/api
// packages/shared/package.json
{
"name": "@myorg/shared",
"version": "0.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts"
}
// packages/shared/src/index.ts
export interface User {
id: string;
email: string;
name: string;
createdAt: Date;
}
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
}
export const API_ENDPOINTS = {
users: "/api/users",
products: "/api/products",
orders: "/api/orders",
} as const;
// apps/web/package.json
{
"name": "@myorg/web",
"dependencies": {
"@myorg/shared": "workspace:*"
}
}
# Install dependencies — pnpm links workspace packages automatically
pnpm install
Now apps/web can import from @myorg/shared:
import { User, formatDate, API_ENDPOINTS } from "@myorg/shared";
Running Scripts
# Run dev in all apps
pnpm -r dev
# Run build only in web app
pnpm --filter @myorg/web build
# Run build in web and all its dependencies
pnpm --filter @myorg/web... build
# Run tests in packages that changed since main
pnpm --filter "...[origin/main]" test
Option 2: Turborepo (Build Orchestration)
Turborepo adds smart task scheduling and caching on top of your package manager. It understands your dependency graph and only rebuilds what changed.
Setup
# Add Turborepo to existing pnpm workspace
pnpm add -D turbo --workspace-root
# Or create a new project
npx create-turbo@latest
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/", "dist/"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"test": {
"dependsOn": ["build"]
},
"typecheck": {
"dependsOn": ["^build"]
}
}
}
Key concepts in turbo.json:
dependsOn: ["^build"]— Runbuildin dependencies first (the^means "upstream packages")outputs— Files that Turborepo caches. On a cache hit, it restores these instead of running the taskcache: false— Don't cache dev servers (they're long-running)persistent: true— This task runs indefinitely (dev servers)
Task Running
# Build everything in dependency order
turbo build
# Build only web and its dependencies
turbo build --filter=@myorg/web...
# Run dev servers for all apps in parallel
turbo dev
# See what would run without actually running
turbo build --dry-run
Caching
Turborepo's cache is its killer feature. After the first build, subsequent builds of unchanged packages are instant — Turborepo replays the cached output instead of running the build command.
$ turbo build
Tasks: 3 successful, 3 total
Cached: 2 cached, 3 total # 2 out of 3 packages were cached
Time: 1.2s # Instead of 45s
Remote caching with Vercel:
npx turbo login
npx turbo link
Now cache is shared across your team and CI. Developer A builds the shared package, developer B gets the cached result without building locally.
Option 3: Nx (Full Build System)
Nx is the most feature-rich option. It auto-detects your dependency graph, generates code, and supports distributed task execution.
Setup
# Create new Nx workspace
npx create-nx-workspace@latest my-workspace
# Or add to existing repo
npx nx@latest init
// nx.json
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"cache": true
},
"lint": {
"cache": true
},
"test": {
"cache": true
}
},
"defaultBase": "main"
}
Generating Projects
Nx has generators for common project types:
# Generate a React app
nx generate @nx/react:app web
# Generate a Node.js library
nx generate @nx/node:library shared-utils
# Generate a component
nx generate @nx/react:component Button --project=web
Affected Commands
Nx knows which projects are affected by a change:
# Only build projects affected by changes since main
nx affected -t build
# Only test affected projects
nx affected -t test
# See the dependency graph in your browser
nx graph
Project Configuration
// apps/web/project.json
{
"name": "web",
"targets": {
"build": {
"executor": "@nx/next:build",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/web"
}
},
"dev": {
"executor": "@nx/next:server",
"options": {
"port": 3000
}
}
}
}
Shared Configuration
One of the biggest wins of a monorepo: shared configuration files.
Shared TypeScript Config
// tsconfig.base.json (root)
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleResolution": "bundler",
"paths": {
"@myorg/shared": ["packages/shared/src"],
"@myorg/ui": ["packages/ui/src"]
}
}
}
// apps/web/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"lib": ["dom", "dom.iterable", "es2022"],
"module": "esnext",
"target": "es2017"
},
"include": ["src/*/", "next-env.d.ts"],
"exclude": ["node_modules"]
}
Shared ESLint Config
// packages/eslint-config/index.js
module.exports = {
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
parser: "@typescript-eslint/parser",
rules: {
"no-console": "warn",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
};
// apps/web/.eslintrc.js
module.exports = {
extends: ["@myorg/eslint-config"],
// App-specific overrides
};
CI/CD Optimization
A naive CI pipeline rebuilds everything on every commit. That's slow and wasteful. Use affected/changed detection:
GitHub Actions with Turborepo
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Need full history for affected detection
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: turbo build lint test --filter="...[origin/main]"
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
The --filter="...[origin/main]" flag tells Turborepo to only run tasks for packages that changed since the main branch. Combined with remote caching, CI for a large monorepo can finish in under a minute.
Common Mistakes
- Starting with a monorepo for a solo project. A monorepo's benefits come from shared code and coordinated deployments. If you have one app and no shared packages, it's overhead for no gain.
- Circular dependencies between packages. Package A imports from B, B imports from A. This breaks build order. Refactor the shared code into a third package.
- Not pinning workspace dependency versions. Use
workspace:*in pnpm to always use the local version. Don't use semver ranges for workspace packages.
- Forgetting
--frozen-lockfilein CI. Without it, CI might install different dependency versions than your local environment, causing phantom build failures.
- Building everything on every PR. Without affected detection or caching, your 20-minute build time will kill developer productivity.