Published on

Monorepo Tooling in 2026 — Nx vs Turborepo for Large TypeScript Codebases

Authors

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 boundaries

Why 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 init

Configure 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.json

Run 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 --verbose

Nx 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/react

Nx 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.json

Configure 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=libs

Shared 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_SUMMARY

Remote Caching with Turborepo Cloud

Push cache to Turborepo Cloud for faster CI:

# Login to Turborepo Cloud
turbo login

# Link workspace
turbo link

Configure 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-team

When 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 layer

Monorepo 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@latest

Checklist

  • 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.