Monorepo Tooling in 2026 — Nx vs Turborepo for Large TypeScript Codebases
Advertisement
Introduction
A monorepo lets you change types in one place and update ten consumers atomically. It forces code ownership, encourages shared libraries, and prevents version skew. Nx and Turborepo are the two mature tools for scaling them. Nx is feature-rich (generators, testing, computation caching). Turborepo is lightweight and fast. This post compares both, walks through real configurations, explains when each shines, and most importantly—when to avoid monorepos entirely.
Monorepo Benefits and Costs
Why use a monorepo:
✓ Atomic refactorings (change types, update all callers in one commit)
✓ Shared code (no versioning dance, no private package registry)
✓ Unified tooling (one eslint, prettier, tsconfig for all)
✓ CI simplification (one pipeline, one dashboard)
✓ Code discovery (find all consumers of a change)
✓ Enforced type safety across boundariesWhy to avoid:
✗ CI bottleneck (all tests run in one pipeline)
✗ Cloned dependencies (node_modules is huge)
✗ Testing burden (every change requires full test suite)
✗ Repository scale (git operations slow at 100k+ files)
✗ Team friction (who's responsible for shared code?)
✗ Difficult migrations (extracting a library is hard)Turborepo Setup and Configuration
Start simple with Turborepo:
npm install -D turbo
npx turbo initConfigure the workspace:
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"packages/*",
"apps/*"
],
"devDependencies": {
"turbo": "^1.11.0"
}
}Define the pipeline:
{
"$schema": "https://turbo.build/json-schema/turbo-json-schema-v1.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"],
"cache": true
},
"test": {
"outputs": ["coverage/**"],
"cache": true,
"inputs": ["src/**", "test/**", "*.config.ts"]
},
"lint": {
"outputs": ["reports/**"],
"cache": true
},
"type-check": {
"cache": true
},
"dev": {
"cache": false,
"persistent": true
},
"generate": {
"cache": false,
"outputs": ["src/generated/**"]
}
},
"globalDependencies": [
".env",
".env.local",
"tsconfig.json",
".eslintrc.json"
],
"globalPassThroughEnv": [
"NODE_ENV"
]
}Workspace structure:
my-monorepo/
├── apps/
│ ├── api/
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── web/
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ ├── shared-types/
│ │ ├── src/
│ │ └── package.json
│ ├── shared-utils/
│ │ ├── src/
│ │ └── package.json
│ └── ui-components/
│ ├── src/
│ └── package.json
├── turbo.json
├── package.json
└── tsconfig.jsonRun tasks with Turborepo:
# Run build in all packages respecting dependencies
turbo run build
# Run only affected packages (changed since main)
turbo run build --filter=[main]
# Run specific package
turbo run build --filter=api
# Run with pruned dependencies only
turbo prune --scope=api
# Watch mode
turbo run dev --parallel
# Output detailed logs
turbo run build --verboseNx Setup and Plugins
Set up Nx with plugins:
npx create-nx-workspace my-monorepo --preset ts
cd my-monorepo
nx add @nx/node
nx add @nx/next
nx add @nx/reactNx workspace structure:
my-monorepo/
├── apps/
│ ├── api/
│ │ ├── src/
│ │ ├── project.json
│ │ └── tsconfig.json
│ └── web/
├── libs/
│ ├── shared-types/
│ │ ├── src/
│ │ ├── project.json
│ │ └── tsconfig.json
│ ├── shared-utils/
│ └── ui-components/
├── nx.json
├── tsconfig.base.json
└── package.jsonConfigure Nx:
{
"extends": "nx/presets/npm.json",
"plugins": [
{
"plugin": "@nx/node/plugin",
"options": {
"projectPattern": "apps/*"
}
},
{
"plugin": "@nx/next/plugin",
"options": {
"projectPattern": "apps/web"
}
},
{
"plugin": "@nx/react/plugin",
"options": {
"projectPattern": "libs/ui-*"
}
}
],
"defaultProject": "api",
"targetDefaults": {
"build": {
"cache": true,
"dependsOn": ["^build"]
},
"test": {
"cache": true,
"inputs": ["!{projectRoot}/**/*.test.ts"]
},
"lint": {
"cache": true
}
},
"namedInputs": {
"default": ["{projectRoot}/**/*.ts"],
"production": [
"{projectRoot}/src/**",
"{projectRoot}/**/*.config.ts"
]
},
"cacheDirectory": ".nx/cache"
}Generate libraries with Nx:
# Generate a shared utilities library
nx g @nx/node:lib shared-utils --directory=libs --unitTestRunner=vitest
# Generate React component library
nx g @nx/react:lib ui-components --directory=libs --bundler=vite
# Generate API endpoints library
nx g @nx/express:lib api-routes --directory=libsShared Types Pattern
Define types once, use everywhere:
// libs/shared-types/src/index.ts
export interface User {
id: string;
email: string;
name: string;
createdAt: Date;
role: 'admin' | 'user' | 'guest';
}
export interface Order {
id: string;
userId: string;
items: OrderItem[];
total: number;
status: 'pending' | 'processing' | 'completed' | 'cancelled';
createdAt: Date;
}
export interface OrderItem {
productId: string;
quantity: number;
price: number;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
};
}
export type UserRole = User['role'];Configure TypeScript path aliases:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@my-monorepo/shared-types": ["libs/shared-types/src/index.ts"],
"@my-monorepo/shared-utils": ["libs/shared-utils/src/index.ts"],
"@my-monorepo/ui-components": ["libs/ui-components/src/index.ts"],
"@api/*": ["apps/api/src/*"],
"@web/*": ["apps/web/src/*"]
}
}
}Use types in applications:
// apps/api/src/routes/users.ts
import { User, ApiResponse } from '@my-monorepo/shared-types';
export async function getUser(id: string): Promise<ApiResponse<User>> {
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
return {
success: !!user,
data: user,
};
}
// apps/web/src/hooks/useUser.ts
import { User } from '@my-monorepo/shared-types';
export function useUser(id: string) {
const [user, setUser] = useState<User | null>(null);
// ...
}Affected Builds and CI Optimization
Only rebuild what changed:
# Show what changed
turbo run build --filter=[main]
# In CI: only run affected tests
turbo run test --filter=[origin/main]GitHub Actions with affected builds:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for comparison
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install
# Only test affected packages
- run: npx turbo run test --filter=[origin/main]
- run: npx turbo run lint --filter=[origin/main]
- run: npx turbo run build --filter=[origin/main]
- name: Comment PR with affected packages
if: github.event_name == 'pull_request'
run: |
AFFECTED=$(npx turbo run build --filter=[origin/main] --dry 2>&1 | grep -o ' [a-z-]*' | sort -u | tr '\n' ', ')
echo "### Affected Packages: $AFFECTED" >> $GITHUB_STEP_SUMMARYRemote Caching with Turborepo Cloud
Push cache to Turborepo Cloud for faster CI:
# Login to Turborepo Cloud
turbo login
# Link workspace
turbo linkConfigure in turbo.json:
{
"remoteCache": {
"enabled": true,
"apiUrl": "https://api.turbo.build",
"signature": true
}
}In CI, cache is shared across builds:
- run: turbo run build test --api="https://api.turbo.build" --token=${{ secrets.TURBO_TOKEN }}Code Ownership with CODEOWNERS
Define who's responsible for each library:
# .github/CODEOWNERS
/apps/api/ @backend-team
/apps/web/ @frontend-team
/libs/shared-types/ @platform-team
/libs/ui-components/ @design-system-team
/libs/shared-utils/ @platform-teamWhen a PR touches a file, owners are automatically requested for review.
When NOT to Use a Monorepo
// ❌ Bad monorepo candidate:
// Multiple languages (Node.js + Go + Python)
// Teams with zero overlap that independently deploy
// Services with different release cadences
// 50+ packages with complex interdependencies
// Large binaries (compiled images, models)
// Separate security/compliance requirements
// ✓ Good monorepo candidate:
// Multiple TypeScript packages
// Shared UI components library
// Atomic changes across API + web
// <20 interdependent packages
// Same deployment cadence
// Shared infrastructure/database layerMonorepo to Polyrepo Migration
Extract a library when it becomes stable:
# In Turborepo: extract package
turbo prune --scope=shared-utils --docker
# Publish to npm
cd out
npm publish
# Update consumers to use published version
npm install @my-org/shared-utils@latestChecklist
- Choose Turborepo for speed, Nx for feature richness
- Set up path aliases for all shared packages
- Define clear package boundaries (no circular deps)
- Configure affected builds for CI
- Document code ownership in CODEOWNERS
- Enable remote caching (Turborepo Cloud or local Nx Cloud)
- Limit to <30 packages initially
- Establish code review policy for shared libs
- Plan extraction path for stable libraries
- Monitor build times monthly
Conclusion
Monorepos work well when teams have high interdependency and shared velocity. They break when teams want independence. Turborepo is faster and lighter; Nx is more powerful. Start with Turborepo, migrate to Nx if you need generators and visual graph analysis. Most critically: define clear package boundaries, use shared types sparingly, and plan the day you'll extract a library into its own repository. A monorepo that's outgrown its purpose is a factory disaster—extracting from it is painful.
Advertisement