JavaScript 17 min read

Turborepo Advanced Patterns: Production-Ready Monorepo Architecture in 2025

Master production Turborepo patterns with real-world examples. Migration guide, CI/CD pipelines, remote caching, testing strategies, and framework-specific configs.

MR

Moshiour Rahman

Advertisement

Building a production monorepo requires more than just create-turbo. This guide covers battle-tested patterns from real SaaS projects, including migration strategies, advanced caching, CI/CD optimization, and framework-specific configurations.

What This Guide Covers

SectionWhat You’ll Learn
Architecture PatternsReal project structures used in production
Migration GuideStep-by-step polyrepo to monorepo
Advanced CachingSelf-hosted S3, cache optimization
CI/CD PatternsGitHub Actions with matrix builds
Testing StrategiesShared configs, coverage aggregation
Framework ConfigsNext.js, Remix, React Native, Electron
TroubleshootingCommon issues and solutions

Production Architecture Patterns

Turborepo Monorepo Architecture

The SaaS Monorepo Structure

Here’s a battle-tested structure for a multi-product SaaS:

my-saas/
├── apps/
│   ├── web/                 # Next.js marketing + app
│   ├── api/                 # Hono/Express API server
│   ├── admin/               # Admin dashboard
│   ├── docs/                # Astro documentation
│   └── mobile/              # React Native app
├── packages/
│   ├── ui/                  # Shared React components
│   ├── database/            # Prisma/Drizzle + migrations
│   ├── auth/                # Shared auth (Clerk/Auth.js)
│   ├── email/               # React Email templates
│   ├── analytics/           # Tracking abstraction
│   ├── validators/          # Zod schemas (shared API contracts)
│   └── config/              # Environment & constants
├── tooling/
│   ├── eslint/              # Shared ESLint configs
│   ├── typescript/          # Base tsconfigs
│   ├── tailwind/            # Tailwind preset
│   └── prettier/            # Prettier config
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── .github/
    └── workflows/
        ├── ci.yml
        └── deploy.yml

Dependency Graph Visualization

Understanding your dependency graph is critical for optimization:

Turborepo Dependency Flow

Root Configuration Files

turbo.json - Production configuration:

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [
    ".env",
    ".env.local",
    ".env.production"
  ],
  "globalEnv": [
    "NODE_ENV",
    "CI",
    "VERCEL"
  ],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": [
        "$TURBO_DEFAULT$",
        ".env",
        ".env.local",
        ".env.production"
      ],
      "outputs": [
        "dist/**",
        ".next/**",
        "!.next/cache/**",
        "build/**"
      ]
    },
    "lint": {
      "dependsOn": ["^lint"],
      "inputs": ["$TURBO_DEFAULT$"],
      "outputs": []
    },
    "typecheck": {
      "dependsOn": ["^typecheck"],
      "inputs": ["$TURBO_DEFAULT$"],
      "outputs": []
    },
    "test": {
      "dependsOn": ["^build"],
      "inputs": [
        "src/**",
        "tests/**",
        "vitest.config.*",
        "jest.config.*"
      ],
      "outputs": ["coverage/**"]
    },
    "test:e2e": {
      "dependsOn": ["^build"],
      "inputs": ["e2e/**", "playwright.config.*"],
      "outputs": ["playwright-report/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "clean": {
      "cache": false
    },
    "db:generate": {
      "cache": false
    },
    "db:push": {
      "cache": false
    }
  }
}

pnpm-workspace.yaml with catalogs (pnpm 9+):

packages:
  - 'apps/*'
  - 'packages/*'
  - 'tooling/*'

# Centralized dependency versions (pnpm 9+)
catalog:
  # React ecosystem
  react: ^19.0.0
  react-dom: ^19.0.0
  next: ^15.1.0

  # Validation
  zod: ^3.24.0

  # Database
  drizzle-orm: ^0.38.0

  # Development
  typescript: ^5.7.0
  vitest: ^2.1.0

  # Styling
  tailwindcss: ^4.0.0

Root package.json:

{
  "name": "my-saas",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "typecheck": "turbo run typecheck",
    "test": "turbo run test",
    "test:e2e": "turbo run test:e2e",
    "clean": "turbo run clean && rm -rf node_modules .turbo",
    "format": "prettier --write \"**/*.{ts,tsx,md,json}\"",
    "db:generate": "turbo run db:generate",
    "db:push": "turbo run db:push"
  },
  "devDependencies": {
    "prettier": "^3.4.0",
    "turbo": "^2.3.0"
  },
  "packageManager": "pnpm@9.15.0",
  "engines": {
    "node": ">=20.0.0",
    "pnpm": ">=9.0.0"
  }
}

Migration: Polyrepo to Monorepo

Migration Strategy Overview

Turborepo Migration Phases

Step-by-Step Migration

Step 1: Create the monorepo foundation

# Create new monorepo
mkdir my-saas && cd my-saas
pnpm init

# Install Turborepo
pnpm add -D turbo

# Create directory structure
mkdir -p apps packages tooling .github/workflows

# Initialize git
git init

Step 2: Migrate with git history preserved

# Add existing repo as remote
git remote add frontend-repo git@github.com:org/frontend.git
git fetch frontend-repo

# Merge into apps/web with history
git merge frontend-repo/main --allow-unrelated-histories \
  -X subtree=apps/web \
  -m "chore: migrate frontend to monorepo"

# Repeat for other repos
git remote add api-repo git@github.com:org/api.git
git fetch api-repo
git merge api-repo/main --allow-unrelated-histories \
  -X subtree=apps/api \
  -m "chore: migrate api to monorepo"

Step 3: Extract shared code

# Create shared packages
mkdir -p packages/ui/src
mkdir -p packages/validators/src
mkdir -p packages/config/src

# Move shared code from apps to packages
# Example: UI components
mv apps/web/src/components/shared/* packages/ui/src/

Step 4: Update package.json files

// apps/web/package.json
{
  "name": "@myorg/web",
  "dependencies": {
    // Use workspace protocol for internal deps
    "@myorg/ui": "workspace:*",
    "@myorg/validators": "workspace:*",
    "@myorg/config": "workspace:*",
    // External deps use catalog
    "react": "catalog:",
    "next": "catalog:"
  }
}

Step 5: Fix import paths

// Before (direct file import)
import { Button } from '../../../shared/components/Button';
import { userSchema } from '../../../shared/validators';

// After (package import)
import { Button } from '@myorg/ui';
import { userSchema } from '@myorg/validators';

Migration Checklist

TaskStatusNotes
Audit existing dependenciesList all unique deps across repos
Identify shared codeUI, validators, utilities
Create monorepo structureapps/, packages/, tooling/
Setup tooling packagesESLint, TypeScript, Prettier
Migrate first app with historyUsually the frontend
Extract shared packagesCreate packages from shared code
Update all importsReplace relative with package imports
Configure CI/CDTest build + deploy pipeline
Test all apps locallyRun dev, build, test
Archive old reposMark as deprecated/archived

Advanced Caching Strategies

Turborepo Cache Flow

Understanding Cache Behavior

Turborepo Cache Flow

Self-Hosted Remote Cache with S3

Option 1: Using turborepo-remote-cache (Node.js)

// cache-server/src/index.ts
import { createServer } from '@vercel/turbo-remote-cache-server';
import { S3Client } from '@aws-sdk/client-s3';

const s3Client = new S3Client({
  region: process.env.AWS_REGION || 'us-east-1',
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

const server = createServer({
  storage: {
    type: 's3',
    bucket: process.env.TURBO_CACHE_BUCKET!,
    client: s3Client,
  },
  auth: {
    type: 'bearer',
    // Generate secure tokens for CI/team
    tokens: process.env.TURBO_TOKENS!.split(','),
  },
});

server.listen(3001, () => {
  console.log('Turbo cache server running on :3001');
});

Option 2: Using Cloudflare R2 (cheaper)

# R2 is S3-compatible and much cheaper for cache
# Set these in CI environment
export TURBO_API="https://your-cache.workers.dev"
export TURBO_TOKEN="your-token"
export TURBO_TEAM="your-team"
// cache-worker/src/index.ts (Cloudflare Worker)
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    // Verify token
    const token = request.headers.get('Authorization')?.replace('Bearer ', '');
    if (token !== env.TURBO_TOKEN) {
      return new Response('Unauthorized', { status: 401 });
    }

    // Handle cache operations
    if (request.method === 'GET' && path.startsWith('/v8/artifacts/')) {
      const key = path.replace('/v8/artifacts/', '');
      const object = await env.TURBO_CACHE.get(key);
      if (!object) return new Response('Not found', { status: 404 });
      return new Response(object.body);
    }

    if (request.method === 'PUT' && path.startsWith('/v8/artifacts/')) {
      const key = path.replace('/v8/artifacts/', '');
      await env.TURBO_CACHE.put(key, request.body);
      return new Response('OK');
    }

    return new Response('Not found', { status: 404 });
  },
};

Cache Optimization Tips

// turbo.json - Optimized caching
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": [
        // Only include files that affect build
        "src/**/*.{ts,tsx,js,jsx}",
        "package.json",
        "tsconfig.json",
        // Exclude test files from build inputs
        "!src/**/*.test.ts",
        "!src/**/*.spec.ts",
        "!**/__tests__/**"
      ],
      "outputs": ["dist/**"]
    },
    "lint": {
      // Lint rarely needs to depend on built deps
      "dependsOn": [],
      "inputs": [
        "src/**/*.{ts,tsx}",
        ".eslintrc.*",
        "eslint.config.*"
      ],
      // Lint has no outputs (faster cache check)
      "outputs": []
    },
    "typecheck": {
      // Typecheck needs built declaration files
      "dependsOn": ["^build"],
      "inputs": [
        "src/**/*.{ts,tsx}",
        "tsconfig.json"
      ],
      "outputs": []
    }
  }
}

Cache Hit Rate Monitoring

# Check cache statistics
turbo run build --summarize

# Output shows cache hit/miss per package
# Tasks:    12 successful, 12 total
# Cached:   10 cached, 2 total (83% hit rate)
# Time:     2.3s (saved 4m 12s)

CI/CD Production Patterns

Turborepo CI/CD Pipeline

GitHub Actions: Full Pipeline

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

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

# Cancel in-progress runs for same PR
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
  TURBO_REMOTE_ONLY: true

jobs:
  # Determine affected packages for matrix
  changes:
    runs-on: ubuntu-latest
    outputs:
      packages: ${{ steps.filter.outputs.changes }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - id: filter
        uses: dorny/paths-filter@v3
        with:
          filters: |
            web:
              - 'apps/web/**'
              - 'packages/ui/**'
              - 'packages/validators/**'
            api:
              - 'apps/api/**'
              - 'packages/database/**'
              - 'packages/validators/**'
            admin:
              - 'apps/admin/**'
              - 'packages/ui/**'

  lint-and-typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4

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

      - run: pnpm install --frozen-lockfile

      - name: Lint
        run: pnpm turbo run lint --filter='...[origin/main]'

      - name: Type Check
        run: pnpm turbo run typecheck --filter='...[origin/main]'

  test:
    runs-on: ubuntu-latest
    needs: lint-and-typecheck
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4

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

      - run: pnpm install --frozen-lockfile

      - name: Run Tests
        run: pnpm turbo run test --filter='...[origin/main]'

      - name: Upload Coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./packages/*/coverage/lcov.info,./apps/*/coverage/lcov.info
          flags: unittests

  build:
    runs-on: ubuntu-latest
    needs: test
    strategy:
      matrix:
        app: [web, api, admin]
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4

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

      - run: pnpm install --frozen-lockfile

      - name: Build ${{ matrix.app }}
        run: pnpm turbo run build --filter=${{ matrix.app }}...

      - name: Upload Build Artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.app }}-build
          path: apps/${{ matrix.app }}/dist
          retention-days: 1

  e2e:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4

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

      - run: pnpm install --frozen-lockfile

      - name: Install Playwright
        run: pnpm exec playwright install --with-deps chromium

      - name: Download web build
        uses: actions/download-artifact@v4
        with:
          name: web-build
          path: apps/web/dist

      - name: Run E2E Tests
        run: pnpm turbo run test:e2e --filter=web

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: apps/web/playwright-report/

  deploy-preview:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    needs: [build, e2e]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/download-artifact@v4
        with:
          name: web-build
          path: apps/web/dist

      - name: Deploy to Vercel Preview
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          working-directory: apps/web

  deploy-production:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    needs: [build, e2e]
    environment: production
    steps:
      - uses: actions/checkout@v4

      - uses: actions/download-artifact@v4
        with:
          name: web-build
          path: apps/web/dist

      - name: Deploy to Vercel Production
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'
          working-directory: apps/web

CI Pipeline Visualization

Turborepo CI/CD Pipeline

Testing Strategies

Shared Test Configuration

tooling/vitest/base.ts:

import { defineConfig } from 'vitest/config';

export const baseConfig = defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov', 'html'],
      exclude: [
        'node_modules/**',
        'dist/**',
        '**/*.d.ts',
        '**/*.config.*',
        '**/index.ts', // Re-exports only
      ],
    },
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    passWithNoTests: true,
  },
});

tooling/vitest/react.ts:

import { defineConfig, mergeConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { baseConfig } from './base';

export const reactConfig = mergeConfig(
  baseConfig,
  defineConfig({
    plugins: [react()],
    test: {
      environment: 'jsdom',
      setupFiles: ['./test/setup.ts'],
    },
  })
);

packages/ui/vitest.config.ts:

import { reactConfig } from '@myorg/vitest-config/react';

export default reactConfig;

Test Structure Per Package

packages/ui/
├── src/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx      # Unit test
│   │   └── index.ts
│   └── index.ts
├── test/
│   └── setup.ts                  # Test setup
├── vitest.config.ts
└── package.json

packages/ui/test/setup.ts:

import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();
});

E2E Testing with Playwright

apps/web/playwright.config.ts:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html'],
    ['github'],
  ],
  use: {
    baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'mobile',
      use: { ...devices['iPhone 14'] },
    },
  ],
  webServer: process.env.CI ? undefined : {
    command: 'pnpm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Coverage Aggregation

scripts/coverage-report.ts:

import { execSync } from 'child_process';
import { readdirSync, existsSync } from 'fs';
import { join } from 'path';

const packages = [
  ...readdirSync('packages').map(p => `packages/${p}`),
  ...readdirSync('apps').map(a => `apps/${a}`),
];

const coverageFiles = packages
  .map(p => join(p, 'coverage/lcov.info'))
  .filter(existsSync);

if (coverageFiles.length > 0) {
  // Merge coverage reports
  execSync(`npx lcov-result-merger '*/coverage/lcov.info' merged-coverage.info`);

  // Generate HTML report
  execSync(`npx genhtml merged-coverage.info -o coverage-report`);

  console.log('Coverage report generated at coverage-report/index.html');
}

Framework-Specific Configurations

Next.js 15 App Router

apps/web/next.config.ts:

import type { NextConfig } from 'next';

const config: NextConfig = {
  // Enable standalone output for Docker
  output: 'standalone',

  // Transpile internal packages
  transpilePackages: [
    '@myorg/ui',
    '@myorg/validators',
    '@myorg/config',
  ],

  // Experimental features
  experimental: {
    // Enable React 19 features
    reactCompiler: true,
    // Optimize package imports
    optimizePackageImports: [
      '@myorg/ui',
      'lucide-react',
    ],
  },

  // Configure for monorepo
  webpack: (config, { isServer }) => {
    // Handle workspace packages
    if (!isServer) {
      config.resolve.fallback = {
        ...config.resolve.fallback,
        fs: false,
      };
    }
    return config;
  },
};

export default config;

Hono API Server

apps/api/src/index.ts:

import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { db } from '@myorg/database';
import { userRouter } from './routes/user';

const app = new Hono();

// Middleware
app.use('*', logger());
app.use('*', cors({
  origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000'],
}));

// Health check
app.get('/health', (c) => c.json({ status: 'ok' }));

// Routes
app.route('/api/users', userRouter);

// Start server
const port = parseInt(process.env.PORT || '4000');
serve({ fetch: app.fetch, port });
console.log(`API running on http://localhost:${port}`);

apps/api/tsconfig.json:

{
  "extends": "@myorg/typescript-config/node.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

React Native

apps/mobile/metro.config.js:

const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const path = require('path');

// Monorepo root
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, '../..');

const config = {
  watchFolders: [monorepoRoot],
  resolver: {
    nodeModulesPaths: [
      path.resolve(projectRoot, 'node_modules'),
      path.resolve(monorepoRoot, 'node_modules'),
    ],
    // Handle workspace packages
    extraNodeModules: {
      '@myorg/ui': path.resolve(monorepoRoot, 'packages/ui'),
      '@myorg/validators': path.resolve(monorepoRoot, 'packages/validators'),
      '@myorg/config': path.resolve(monorepoRoot, 'packages/config'),
    },
  },
};

module.exports = mergeConfig(getDefaultConfig(projectRoot), config);

Electron with Vite

apps/desktop/electron.vite.config.ts:

import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';

export default defineConfig({
  main: {
    plugins: [externalizeDepsPlugin()],
    build: {
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'src/main/index.ts'),
        },
      },
    },
  },
  preload: {
    plugins: [externalizeDepsPlugin()],
    build: {
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'src/preload/index.ts'),
        },
      },
    },
  },
  renderer: {
    plugins: [react()],
    resolve: {
      alias: {
        '@myorg/ui': resolve(__dirname, '../../packages/ui/src'),
      },
    },
  },
});

Docker with turbo prune

Optimized Multi-Stage Dockerfile

# apps/api/Dockerfile
FROM node:22-alpine AS base
RUN corepack enable

# Prune stage - only copy what's needed
FROM base AS pruner
WORKDIR /app
RUN npm install -g turbo
COPY . .
RUN turbo prune @myorg/api --docker

# Install dependencies
FROM base AS deps
WORKDIR /app
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN pnpm install --frozen-lockfile

# Build stage
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/ .
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo run build --filter=@myorg/api...

# Production stage
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production

# Don't run as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 appuser

COPY --from=builder --chown=appuser:nodejs /app/apps/api/dist ./dist
COPY --from=builder --chown=appuser:nodejs /app/apps/api/package.json ./
COPY --from=builder --chown=appuser:nodejs /app/node_modules ./node_modules

USER appuser
EXPOSE 4000
CMD ["node", "dist/index.js"]

Docker Compose for Development

# docker-compose.yml
version: '3.8'

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: myapp
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'

  api:
    build:
      context: .
      dockerfile: apps/api/Dockerfile
      target: deps  # For development
    volumes:
      - ./apps/api/src:/app/apps/api/src
      - ./packages:/app/packages
    ports:
      - '4000:4000'
    environment:
      DATABASE_URL: postgres://dev:dev@postgres:5432/myapp
      REDIS_URL: redis://redis:6379
    depends_on:
      - postgres
      - redis

volumes:
  postgres_data:

Dependency Management

Version Synchronization with Changesets

Setup changesets:

pnpm add -D @changesets/cli
pnpm changeset init

.changeset/config.json:

{
  "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [],
  "linked": [
    ["@myorg/ui", "@myorg/validators", "@myorg/config"]
  ],
  "access": "restricted",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@myorg/web", "@myorg/api", "@myorg/admin"]
}

Release workflow:

# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: pnpm/action-setup@v4

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

      - run: pnpm install --frozen-lockfile

      - name: Create Release PR or Publish
        uses: changesets/action@v1
        with:
          publish: pnpm run release
          version: pnpm run version
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Renovate for Monorepos

renovate.json:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "config:recommended",
    ":semanticCommits",
    "group:monorepos"
  ],
  "labels": ["dependencies"],
  "packageRules": [
    {
      "groupName": "React ecosystem",
      "matchPackagePatterns": ["^react", "^@types/react"],
      "matchUpdateTypes": ["minor", "patch"]
    },
    {
      "groupName": "Testing",
      "matchPackagePatterns": ["vitest", "playwright", "@testing-library"],
      "matchUpdateTypes": ["minor", "patch"]
    },
    {
      "groupName": "Linting",
      "matchPackagePatterns": ["eslint", "prettier", "@typescript-eslint"],
      "matchUpdateTypes": ["minor", "patch"]
    },
    {
      "matchDepTypes": ["devDependencies"],
      "matchUpdateTypes": ["patch"],
      "automerge": true
    }
  ],
  "postUpdateOptions": ["pnpmDedupe"]
}

Troubleshooting Guide

Common Issues Decision Tree

Turborepo Troubleshooting

Solutions Table

ProblemCauseSolution
Cache never hitsOutputs not specifiedAdd "outputs": ["dist/**"] to turbo.json
Cache always missesEnv vars not declaredAdd env vars to globalEnv or task env
Types not foundMissing declaration filesEnsure "declaration": true in tsconfig
Workspace package not foundNot in pnpm-workspace.yamlAdd path to workspace config
Build order wrongMissing dependsOnAdd "^build" to dependsOn array
Dev server crashesCircular dependencyUse --graph to identify cycles
Hot reload brokenWatch mode not configuredAdd "persistent": true to dev task
Docker build slowNot using turbo pruneUse multi-stage build with prune
CI timeoutNo remote cacheSetup Vercel or self-hosted cache
Duplicate dependenciesNo pnpm catalogsUse catalog: for shared versions

Debug Commands

# Visualize task dependencies
turbo run build --graph

# Check what will run
turbo run build --dry-run

# See cache status
turbo run build --summarize

# Force rebuild (ignore cache)
turbo run build --force

# Run with verbose output
turbo run build --verbosity=2

# See what files are inputs
turbo run build --dry-run=json | jq '.packages[].inputs'

Performance Benchmarks

Real benchmarks from a 10-package monorepo:

ScenarioWithout TurboWith TurboImprovement
Full build (cold)4m 32s4m 28s~1%
Full build (local cache)4m 32s8s97%
Single package change4m 32s45s83%
CI with remote cache4m 32s12s96%
Lint (all packages)2m 15s35s74%
Typecheck (incremental)1m 45s15s86%

Cache Size Management

# Check local cache size
du -sh .turbo/cache

# Clear local cache
turbo daemon clean

# Prune old cache entries (keep last 7 days)
find .turbo/cache -type f -mtime +7 -delete

Summary

PatternWhen to Use
pnpm catalogsSync versions across all packages
workspace:*Reference internal packages
turbo pruneDocker builds with minimal context
—filter=[HEAD^1]CI runs on changed packages only
Remote cacheTeam collaboration, faster CI
ChangesetsPublishing packages with changelogs
Matrix buildsParallel CI for multiple apps
Project referencesTypeScript-aware dependencies

Essential Commands

# Development
pnpm dev                              # Start all dev servers
pnpm turbo run dev --filter=web       # Start specific app

# Building
pnpm build                            # Build all
pnpm turbo run build --filter=web...  # Build app + deps

# Testing
pnpm test                             # Test all
pnpm turbo run test --filter='...[HEAD^1]'  # Test changed

# CI
pnpm turbo run build lint test --filter='...[origin/main]'

# Maintenance
pnpm turbo run clean                  # Clean all
turbo daemon clean                    # Clear cache

Turborepo transforms monorepo development from painful to productive. Start with the basics, add caching, then optimize incrementally based on your bottlenecks.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.