
Introduction
As teams grow and codebases expand, managing multiple repositories becomes increasingly difficult. Dependencies drift apart, tooling configurations diverge, and coordinating changes across repositories requires significant overhead. Monorepos solve these problems by keeping related projects in a single repository, enabling atomic commits, shared tooling, and consistent dependency management. Tools like Nx and Turborepo make monorepos fast, scalable, and developer-friendly by providing intelligent caching, task orchestration, and dependency graph analysis. In this comprehensive guide, you will learn when a monorepo makes sense, how to set up both Nx and Turborepo, and which tool fits your team best based on real-world scenarios and practical code examples.
Why Teams Choose a Monorepo
Before adopting a monorepo, it helps to understand the tangible benefits that organizations like Google, Microsoft, Meta, and many startups have discovered through years of experience.
• Shared code is easier to reuse without publishing packages
• Dependencies stay consistent across all projects
• Refactors span projects safely with atomic commits
• Tooling and scripts are unified reducing configuration drift
• CI pipelines become simpler with single source of truth
• Code review improves when changes are visible in context
• Onboarding accelerates with everything in one place
As a result, teams move faster, reduce long-term maintenance costs, and eliminate the coordination overhead of managing multiple repositories.
Common Monorepo Challenges
However, monorepos also introduce risks if not managed well. Without proper tooling, the benefits quickly turn into liabilities.
• Slow builds without proper caching and affected detection
• Complex dependency graphs that become hard to understand
• Large pull requests that touch many projects
• Tooling complexity requiring specialized knowledge
• CI performance issues running all tests for every change
• Git performance degradation with large histories
• IDE slowdowns indexing massive codebases
This is where Nx and Turborepo add real value. They solve the scale problem while preserving the benefits.
What Is Nx
Nx is a powerful monorepo platform with deep tooling, strong project awareness, and comprehensive plugin ecosystem. It works exceptionally well for large teams and complex systems that need structure and governance.
Core Features of Nx
• Smart task scheduling with parallel execution
• Dependency graph visualization for understanding relationships
• Affected-only builds and tests based on git diff
• Built-in code generators for consistent project creation
• Strong TypeScript support with type-checking across projects
• Plugin ecosystem for React, Angular, Node, and more
• Nx Cloud for distributed caching and task execution
Setting Up Nx Workspace
# Create a new Nx workspace
npx create-nx-workspace@latest my-workspace --preset=ts
# Interactive prompts will guide you through:
# - Choose a preset (apps, ts, react, angular, node, etc.)
# - Enable Nx Cloud for distributed caching
# - Choose package manager (npm, yarn, pnpm)
cd my-workspace
Nx Workspace Structure
my-workspace/
├── apps/ # Application projects
│ ├── web-app/
│ │ ├── src/
│ │ └── project.json
│ └── api/
│ ├── src/
│ └── project.json
├── libs/ # Shared libraries
│ ├── shared/
│ │ └── ui/
│ │ ├── src/
│ │ └── project.json
│ └── feature/
│ └── auth/
│ ├── src/
│ └── project.json
├── tools/ # Custom tooling and generators
├── nx.json # Nx configuration
├── tsconfig.base.json # Base TypeScript config
└── package.json
nx.json Configuration
// nx.json
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
"!{projectRoot}/tsconfig.spec.json",
"!{projectRoot}/.eslintrc.json"
],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"],
"cache": true
},
"lint": {
"inputs": ["default", "{workspaceRoot}/.eslintrc.json"],
"cache": true
},
"e2e": {
"inputs": ["default", "^production"],
"cache": true
}
},
"parallel": 3,
"cacheDirectory": "node_modules/.cache/nx",
"defaultBase": "main"
}
project.json Configuration
// apps/web-app/project.json
{
"name": "web-app",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/web-app/src",
"tags": ["scope:web", "type:app"],
"targets": {
"build": {
"executor": "@nx/vite:build",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/web-app"
},
"configurations": {
"production": {
"mode": "production"
},
"development": {
"mode": "development"
}
},
"defaultConfiguration": "production"
},
"serve": {
"executor": "@nx/vite:dev-server",
"options": {
"buildTarget": "web-app:build",
"port": 4200
},
"configurations": {
"production": {
"buildTarget": "web-app:build:production"
},
"development": {
"buildTarget": "web-app:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"passWithNoTests": true,
"reportsDirectory": "../../coverage/apps/web-app"
}
},
"lint": {
"executor": "@nx/eslint:lint",
"options": {
"lintFilePatterns": ["apps/web-app/**/*.{ts,tsx,js,jsx}"]
}
}
}
}
Creating Libraries with Nx
# Generate a new React library
npx nx g @nx/react:library ui --directory=libs/shared/ui
# Generate a Node.js library
npx nx g @nx/node:library utils --directory=libs/shared/utils
# Generate with specific tags for dependency constraints
npx nx g @nx/react:library auth --directory=libs/feature/auth --tags="scope:shared,type:feature"
Nx Dependency Constraints
Enforce architectural boundaries using module boundary rules.
// .eslintrc.json (root)
{
"root": true,
"plugins": ["@nx"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{
"sourceTag": "type:app",
"onlyDependOnLibsWithTags": ["type:feature", "type:ui", "type:util"]
},
{
"sourceTag": "type:feature",
"onlyDependOnLibsWithTags": ["type:ui", "type:util", "type:data-access"]
},
{
"sourceTag": "type:ui",
"onlyDependOnLibsWithTags": ["type:ui", "type:util"]
},
{
"sourceTag": "type:util",
"onlyDependOnLibsWithTags": ["type:util"]
},
{
"sourceTag": "scope:web",
"onlyDependOnLibsWithTags": ["scope:web", "scope:shared"]
},
{
"sourceTag": "scope:api",
"onlyDependOnLibsWithTags": ["scope:api", "scope:shared"]
}
]
}
]
}
}
]
}
Running Nx Commands
# Run a single project target
npx nx build web-app
npx nx test shared-ui
npx nx lint api
# Run affected projects only (based on git diff)
npx nx affected:build
npx nx affected:test
npx nx affected:lint
# Run for all projects
npx nx run-many -t build
npx nx run-many -t test --parallel=5
# Visualize dependency graph
npx nx graph
# Show what would be affected by current changes
npx nx affected:graph
What Is Turborepo
Turborepo focuses on speed and simplicity. It works on top of existing package managers and adds powerful caching without requiring major restructuring of your codebase.
Core Features of Turborepo
• Incremental builds only rebuilding what changed
• Local and remote caching via Vercel or self-hosted
• Simple configuration with turbo.json
• Works with npm, pnpm, and yarn workspaces
• Minimal learning curve for teams familiar with npm scripts
• Parallel execution with intelligent scheduling
• Zero runtime – just a task runner
Setting Up Turborepo
# Create a new Turborepo workspace
npx create-turbo@latest my-turborepo
# Or add Turborepo to existing workspace
cd existing-monorepo
npm install turbo --save-dev
Turborepo Workspace Structure
my-turborepo/
├── apps/ # Application packages
│ ├── web/
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── api/
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── packages/ # Shared packages
│ ├── ui/
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── config-eslint/
│ │ ├── index.js
│ │ └── package.json
│ └── config-typescript/
│ ├── base.json
│ └── package.json
├── turbo.json # Turborepo configuration
├── package.json # Root package.json
└── pnpm-workspace.yaml # Workspace definition (for pnpm)
Root package.json
// package.json
{
"name": "my-turborepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"clean": "turbo run clean && rm -rf node_modules",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
"prettier": "^3.2.0",
"turbo": "^2.0.0"
},
"packageManager": "pnpm@8.15.0",
"workspaces": [
"apps/*",
"packages/*"
]
}
turbo.json Configuration
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"globalEnv": ["NODE_ENV"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".eslintrc.js"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["$TURBO_DEFAULT$", "**/*.test.ts", "**/*.test.tsx"],
"outputs": ["coverage/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
}
}
}
Package-Specific Configuration
// apps/web/package.json
{
"name": "@myorg/web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run"
},
"dependencies": {
"@myorg/ui": "workspace:*",
"next": "^14.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@myorg/config-eslint": "workspace:*",
"@myorg/config-typescript": "workspace:*",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0"
}
}
// packages/ui/package.json
{
"name": "@myorg/ui",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup src/index.tsx --format esm,cjs --dts",
"dev": "tsup src/index.tsx --format esm,cjs --dts --watch",
"lint": "eslint src/",
"clean": "rm -rf dist"
},
"dependencies": {
"react": "^18.2.0"
},
"devDependencies": {
"@myorg/config-eslint": "workspace:*",
"@myorg/config-typescript": "workspace:*",
"@types/react": "^18.2.0",
"tsup": "^8.0.0",
"typescript": "^5.3.0"
}
}
Shared UI Component Example
// packages/ui/src/index.tsx
import * as React from "react";
export interface ButtonProps {
children: React.ReactNode;
variant?: "primary" | "secondary" | "danger";
size?: "sm" | "md" | "lg";
disabled?: boolean;
onClick?: () => void;
}
export function Button({
children,
variant = "primary",
size = "md",
disabled = false,
onClick
}: ButtonProps) {
const baseStyles = "rounded font-medium transition-colors focus:outline-none focus:ring-2";
const variantStyles = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500"
};
const sizeStyles = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg"
};
return (
);
}
export interface CardProps {
children: React.ReactNode;
title?: string;
className?: string;
}
export function Card({ children, title, className = "" }: CardProps) {
return (
{title && {title}
}
{children}
);
}
Running Turborepo Commands
# Run build for all packages
pnpm turbo run build
# Run dev mode for all packages (parallel, no caching)
pnpm turbo run dev
# Run specific packages
pnpm turbo run build --filter=@myorg/web
pnpm turbo run build --filter=@myorg/ui
# Run affected packages (based on git)
pnpm turbo run build --filter=...[HEAD~1]
# Run with specific concurrency
pnpm turbo run build --concurrency=10
# Show dependency graph
pnpm turbo run build --graph
# Dry run to see what would execute
pnpm turbo run build --dry-run
Task Execution and Caching
Both tools optimize task execution through intelligent caching, but they approach it differently.
Nx Caching Mechanism
Nx uses computation hashing to determine if a task needs to run.
# First run - executes build
$ npx nx build web-app
> nx run web-app:build
Building...
Done in 45s
# Second run - uses cache
$ npx nx build web-app
> nx run web-app:build [existing outputs match the cache, left as is]
Done in 0.5s
Nx computes a hash based on:
• Source files in the project
• Dependencies in the dependency graph
• Environment variables
• Runtime configuration
• External dependencies versions
Turborepo Caching Mechanism
Turborepo uses similar hash-based caching with configurable inputs and outputs.
# First run
$ pnpm turbo run build
• Packages in scope: @myorg/ui, @myorg/web, @myorg/api
• Running build in 3 packages
• Remote caching disabled
@myorg/ui:build: cache miss, executing
@myorg/web:build: cache miss, executing
@myorg/api:build: cache miss, executing
Tasks: 3 successful, 3 total
Cached: 0 cached, 3 total
Time: 42.5s
# Second run
$ pnpm turbo run build
@myorg/ui:build: cache hit, replaying output
@myorg/web:build: cache hit, replaying output
@myorg/api:build: cache hit, replaying output
Tasks: 3 successful, 3 total
Cached: 3 cached, 3 total
Time: 0.8s
Remote Caching
Both tools support remote caching to share build artifacts across team members and CI.
# Nx Cloud setup
npx nx connect-to-nx-cloud
# Turborepo with Vercel
npx turbo login
npx turbo link
CI/CD Integration
Monorepos shine when CI pipelines are optimized to only run affected tasks.
Nx GitHub Actions Workflow
# .github/workflows/ci.yml (Nx)
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Set NX_BASE and NX_HEAD
uses: nrwl/nx-set-shas@v4
- name: Run affected lint
run: pnpm nx affected:lint --base=${{ env.NX_BASE }} --head=${{ env.NX_HEAD }}
- name: Run affected test
run: pnpm nx affected:test --base=${{ env.NX_BASE }} --head=${{ env.NX_HEAD }}
- name: Run affected build
run: pnpm nx affected:build --base=${{ env.NX_BASE }} --head=${{ env.NX_HEAD }}
Turborepo GitHub Actions Workflow
# .github/workflows/ci.yml (Turborepo)
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm turbo run lint
- name: Type check
run: pnpm turbo run typecheck
- name: Test
run: pnpm turbo run test
- name: Build
run: pnpm turbo run build
Nx vs Turborepo: Feature Comparison
| Feature | Nx | Turborepo |
|----------------------------|-------------------------|-------------------------|
| Task caching | ✅ Local + Cloud | ✅ Local + Vercel |
| Affected detection | ✅ Git-based | ✅ Git-based |
| Dependency graph | ✅ Interactive UI | ✅ GraphViz output |
| Code generation | ✅ Built-in generators | ❌ Use external tools |
| Module boundaries | ✅ ESLint rules | ❌ Manual enforcement |
| Plugin ecosystem | ✅ Rich ecosystem | ❌ Minimal |
| Framework support | ✅ Deep integration | ✅ Framework agnostic |
| Configuration complexity | Higher | Lower |
| Learning curve | Steeper | Gentler |
| Migration from existing | More restructuring | Minimal changes |
| Remote execution | ✅ Nx Agents | ❌ Caching only |
When to Use Nx
Nx is a strong choice when you need:
• Large monorepos with many apps and libraries
• Clear dependency boundaries enforced by tooling
• Advanced code generation for consistent project creation
• Strong architectural enforcement through lint rules
• Deep framework integration (Angular, React, Node)
• Distributed task execution across multiple machines
• Long-term scalability for enterprise organizations
Nx excels when structure becomes an advantage rather than overhead.
When to Use Turborepo
Turborepo works best when you want:
• Quick monorepo adoption with minimal changes
• Simple configuration without new concepts
• Fast builds through caching as the primary benefit
• Flexibility over strict rules for team autonomy
• Minimal tooling overhead and learning curve
• Existing npm/pnpm/yarn workflows to remain unchanged
• Vercel deployment integration out of the box
For many JavaScript teams, this balance is ideal.
Common Mistakes to Avoid
When working with monorepos, teams often encounter these pitfalls that reduce the benefits.
1. Adopting a Monorepo Too Early
// ❌ Bad: Single small project in a monorepo
my-monorepo/
└── apps/
└── my-only-app/ // Only one app, no shared code
// ✅ Good: Wait until you have shared concerns
// - Multiple apps sharing components
// - Shared utilities across projects
// - Team coordination challenges
2. Ignoring Dependency Boundaries
// ❌ Bad: Circular dependencies and spaghetti imports
import { something } from "../../../apps/other-app/src/utils";
// ✅ Good: Clear library boundaries
import { something } from "@myorg/shared-utils";
3. Not Configuring Caching Properly
// ❌ Bad: Missing outputs means cache can't restore
{
"pipeline": {
"build": {
"dependsOn": ["^build"]
// Missing outputs!
}
}
}
// ✅ Good: Explicit outputs for proper caching
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
}
}
}
4. Skipping CI Optimization
# ❌ Bad: Running everything on every PR
pnpm turbo run build test lint
# ✅ Good: Only run affected tasks
pnpm turbo run build test lint --filter=...[origin/main]
5. Inconsistent Package Versions
// ❌ Bad: Different React versions across packages
// apps/web/package.json
{ "dependencies": { "react": "^17.0.0" } }
// apps/dashboard/package.json
{ "dependencies": { "react": "^18.2.0" } }
// ✅ Good: Single version in root, workspace protocol
// package.json (root)
{ "dependencies": { "react": "^18.2.0" } }
// apps/web/package.json
{ "dependencies": { "react": "workspace:*" } }
6. Overcomplicating Library Structure
// ❌ Bad: Too many tiny libraries
libs/
├── button/
├── card/
├── modal/
├── input/
└── ... (100 more component libs)
// ✅ Good: Logical groupings
libs/
├── ui-components/ # All UI primitives
├── feature-auth/ # Auth feature
├── data-access-api/ # API client
└── util-formatting/ # Formatting utilities
Conclusion
Monorepos can greatly improve collaboration, consistency, and developer velocity when used correctly. Tools like Nx and Turborepo make monorepos practical by solving build performance and tooling challenges through intelligent caching, task orchestration, and dependency analysis. Nx provides a comprehensive platform with deep tooling, code generation, and architectural enforcement ideal for large organizations. Turborepo offers a lightweight, flexible approach that integrates seamlessly with existing workflows ideal for teams that value simplicity.
The right choice depends more on team needs, project scale, and organizational culture than raw features. Both tools significantly improve developer experience compared to naive monorepo setups or multi-repo architectures.
If you want to improve large-scale JavaScript workflows, read Modern ECMAScript Features You Might Have Missed. For automation and delivery strategies, see CI/CD for Node.js Projects Using GitHub Actions. To understand the Deno runtime that takes a different approach to package management, check out Introduction to Deno: A Modern Runtime for TypeScript and JavaScript. You can also explore the Nx documentation and the Turborepo documentation. By choosing the right monorepo tool, teams can scale codebases without slowing development.
1 Comment