Monorepo Design Patterns - Scalable Development with Turborepo, pnpm, and Nx

2025.12.02

What is a Monorepo

A Monorepo (Monorepo) is an architectural pattern that manages multiple projects or packages in a single repository. It’s adopted by large companies like Google, Meta, and Microsoft.

flowchart TB
    subgraph Polyrepo["Polyrepo"]
        RA["repo-a<br/>pkg.json"] --> npm1["npm"]
        RB["repo-b<br/>pkg.json"] --> npm2["npm"]
        RC["repo-c<br/>pkg.json"] --> npm3["npm"]
        RD["repo-d<br/>pkg.json"] --> npm4["npm"]
    end

    subgraph Monorepo["Monorepo (single-repo)"]
        subgraph Packages["Shared dependencies"]
            UI["packages/ui"]
            Utils["packages/utils"]
            Web["apps/web"]
        end
    end

Benefits and Drawbacks of Monorepos

Benefits

BenefitDescription
1. Easy Code Sharingpackages/shared → apps/web (Immediately importable, no version management)
2. Atomic CommitsComplete changes to multiple packages in 1 commit → Release breaking changes and fixes together
3. Unified ToolchainCentralized ESLint, TypeScript, test config
4. Dependency VisualizationClear view of inter-package dependencies

Drawbacks and Solutions

DrawbackSolution
Repository size growthSparse checkout, shallow clone
CI execution time increaseDifferential builds, parallel execution, remote cache
Permission management complexityCODEOWNERS, directory-level permissions
Increased conflictsAppropriate package splitting, clear responsibility boundaries

Directory Structure Pattern

monorepo/
├── apps/                      # Applications
│   ├── web/                   # Frontend
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── api/                   # Backend
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── mobile/                # Mobile app
│       └── ...
├── packages/                  # Shared packages
│   ├── ui/                    # UI components
│   │   ├── src/
│   │   │   ├── Button/
│   │   │   ├── Modal/
│   │   │   └── index.ts
│   │   └── package.json
│   ├── utils/                 # Utility functions
│   │   └── ...
│   ├── config/                # Shared configuration
│   │   ├── eslint/
│   │   ├── typescript/
│   │   └── tailwind/
│   └── types/                 # Shared type definitions
│       └── ...
├── tools/                     # Development tools/scripts
│   ├── scripts/
│   └── generators/
├── package.json               # Root package.json
├── pnpm-workspace.yaml        # Workspace configuration
├── turbo.json                 # Turborepo configuration
└── tsconfig.base.json         # Base TypeScript configuration

pnpm Workspaces Configuration

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
  - "tools/*"
// Root package.json
{
  "name": "monorepo",
  "private": true,
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "clean": "turbo run clean && rm -rf node_modules"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.4.0"
  },
  "packageManager": "pnpm@9.0.0"
}

Internal Package References

// apps/web/package.json
{
  "name": "@monorepo/web",
  "dependencies": {
    "@monorepo/ui": "workspace:*",
    "@monorepo/utils": "workspace:*",
    "react": "^19.0.0"
  }
}
// apps/web/src/App.tsx
import { Button, Modal } from '@monorepo/ui';
import { formatDate, debounce } from '@monorepo/utils';

export function App() {
  return (
    <div>
      <Button onClick={() => console.log(formatDate(new Date()))}>
        Click
      </Button>
    </div>
  );
}

Build Optimization with Turborepo

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", ".env*"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "outputs": []
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}

Task Dependency Visualization

When running turbo run build:

flowchart TB
    Types["@repo/types<br/>build"] --> Utils["@repo/utils<br/>build"]
    Utils --> UI["@repo/ui<br/>build"]
    Utils --> API["@repo/api<br/>build"]
    Utils --> Web["@repo/web<br/>build"]
  • ^build: Build dependent packages first
  • Parallel execution: Tasks without dependencies run simultaneously

Remote Cache Configuration

# Vercel Remote Cache
npx turbo login
npx turbo link

# Self-hosted cache (ducktape)
# turbo.json
{
  "remoteCache": {
    "signature": true
  }
}

Turborepo vs Nx Comparison

AspectTurborepoNx
Learning CurveLowMedium-High
ConfigurationSimpleFeature-rich
GeneratorsNoneExtensive
PluginsLimitedAbundant
CacheVercel integratedNx Cloud
Dependency AnalysisBasicDetailed
IDE IntegrationBasicVSCode extension

Selection criteria:

  • Simplicity priority → Turborepo
  • Enterprise features → Nx
  • Using Vercel → Turborepo
  • Using Angular → Nx

CI/CD Strategy

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # For diff detection

      - uses: pnpm/action-setup@v3
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm turbo run build --filter="...[HEAD^1]"
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

      - name: Test
        run: pnpm turbo run test --filter="...[HEAD^1]"

      - name: Lint
        run: pnpm turbo run lint --filter="...[HEAD^1]"

Filtering Syntax

# Build only changed packages
turbo run build --filter="...[HEAD^1]"

# Specific package and its dependencies
turbo run build --filter="@repo/web..."

# Specific package and its dependents
turbo run build --filter="...@repo/ui"

# Only under specific directory
turbo run build --filter="./apps/*"

Best Practices

  1. Clear package responsibilities: One package, one responsibility
  2. Avoid circular dependencies: Dependency graph should always be a DAG
  3. Versioning strategy: Unified version management with Changesets, etc.
  4. Documentation: Place README in each package
  5. Appropriate granularity: Not too fine, not too large package splitting
← Back to list