We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide21 min read

Turborepo, faster builds in a monorepo

Turborepo speeds up monorepo builds through result caching and parallelism. Task configuration, remote caching, and the mistakes that undo it.

Turborepo, faster builds in a monorepo

Turborepo is a tool that shortens build times in a repository holding many packages. It does so two ways: by remembering task results and skipping those whose inputs have not changed, and by running in parallel everything that does not depend on anything else. Vercel develops it under MIT, and the current tool version is 2.10.9.

The problem it solves

A monorepo carries one obvious advantage and one non obvious drawback, and this tool addresses only the second.

The advantage is well known: shared code lives in one place, a change to a library and to an application travels in one pull request, and internal dependency versions never drift apart. The drawback surfaces at the first serious growth: building everything takes longer and longer, because every change rebuilds everything, including packages nobody touched.

The idea is simple and rests on two observations. First, if a task's input has not changed, its output will not either, so it can be restored from cache rather than recomputed. Second, packages in a monorepo form a dependency graph, and unrelated tasks can run in parallel instead of in sequence.

The effect can be dramatic precisely because in a typical repository, say one holding several Next.js applications, most changes touch a small part of the code. A change in one application does not require rebuilding the other four, nor libraries that application does not depend on, and without a tool of this kind that would happen anyway.

It is worth knowing what it does not do, though. It does not manage dependencies, since package manager workspaces handle that. It does not publish packages or handle versioning. Nor does it speed up building a single package, it only skips that build when skipping is possible. If your single build takes eight minutes, it will still take eight minutes afterwards, just less often.

The cache key, the heart of the matter

Effectiveness rests entirely on what the tool treats as a task's input, and that is where most problems originate.

The key is a hash covering the package's source files, its dependencies, tool versions, and environment variables declared in configuration. If all of that matches, the result comes from cache. If anything changed, the task runs again.

The most common source of trouble follows: undeclared environment variables. If a build depends on a variable holding an API address and that variable is not listed in configuration, the tool treats two different inputs as identical and hands back a result built for another environment. The symptom is a production application querying a test server, and the cause is one missing configuration line.

The reverse problem occurs too and is less dangerous, though more irritating. Inputs defined too broadly mean the cache never hits, because anything changing anywhere in the repository changes the key with it. Narrowing inputs to files that genuinely affect the result can turn the tool from useless into effective.

The third thing is outputs. A task has to declare which files it produces, because only those enter the cache and only those are restored on a hit. A forgotten output directory shows up as a task finishing instantly with none of its work present, which is often mistaken for a bug in the tool itself.

Worth knowing alongside that: the tool has a mode explaining why the cache missed. Running a task at a raised verbosity level shows the computed hash and what went into it, so instead of guessing you can compare two runs and see which input differs. That is the first thing to do when build times fail to improve after adoption, and it usually locates the cause within minutes.

A separate matter concerns tasks producing no files, type checking or running tests for instance. Their result is the fact of success alone, so they are declared with an empty output list. Caching still works and still makes sense, because what gets skipped is the execution itself, and that is usually the expensive part.

What is Turborepo?

Turborepo is a high-performance build system designed specifically for JavaScript and TypeScript monorepos. Acquired by Vercel in 2021, it has become one of the most popular tools for managing large codebases. The core idea behind Turborepo is to speed up builds through intelligent caching, parallel execution, and incremental builds.

In a traditional approach, building a monorepo means running the same tasks repeatedly, even when the code hasn't changed. Turborepo solves this problem by remembering the results of previous builds and skipping unnecessary work. The result? Builds that used to take minutes now finish in seconds.

Why Turborepo?

Key advantages of Turborepo

  1. Intelligent caching - Doesn't rebuild what's already built
  2. Parallel execution - Utilizes all CPU cores
  3. Remote caching - Share cache between team and CI
  4. Incremental builds - Build only what changed
  5. Pipeline definition - Define dependencies between tasks
  6. Zero config - Works with npm, pnpm, yarn workspaces
  7. Vercel integration - Native Vercel integration
  8. Pruned subsets - Deploy only the packages you need

Turborepo vs other build systems

FeatureTurborepoNxLernaRush
CachingLocal + remoteLocal + remoteLocal + remote (via Nx)Local + remote
SetupMinimalComplexSimpleComplex
Learning curveEasyMediumEasyHard
Remote cacheVercel (free)Nx CloudNx CloudAzure Blob or S3
EcosystemGrowingLargeMaintained by the Nx teamEnterprise
Task runnerBuilt-inBuilt-innpm/yarnCustom
PriceFree + Vercel tiersFree + Cloud tiersFreeFree

When to choose Turborepo?

  • Speed is a priority - fastest time to a working build
  • Already using Vercel - native integration
  • Simple setup - minimal configuration
  • Using npm/pnpm/yarn workspaces - zero migration effort
  • Need remote cache - sharing between CI and developers

Installation and Configuration

New project

Code
Bash
# Create a new monorepo with Turborepo
npx create-turbo@latest my-monorepo

# Or with a specific template
npx create-turbo@latest my-monorepo --example with-tailwind
npx create-turbo@latest my-monorepo --example kitchen-sink

# With pnpm
pnpm dlx create-turbo@latest my-monorepo

Adding to an existing project

Code
Bash
# Install Turborepo
npm install turbo --save-dev

# Or globally
npm install turbo --global

# With pnpm
pnpm add turbo --save-dev --workspace-root

Project structure

Code
TEXT
my-monorepo/
β”œβ”€β”€ apps/
β”‚   β”œβ”€β”€ web/                    # Next.js app
β”‚   β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ package.json
β”‚   β”‚   └── tsconfig.json
β”‚   β”œβ”€β”€ docs/                   # Documentation
β”‚   β”‚   β”œβ”€β”€ src/
β”‚   β”‚   └── package.json
β”‚   └── admin/                  # Admin panel
β”‚       └── package.json
β”œβ”€β”€ packages/
β”‚   β”œβ”€β”€ ui/                     # Shared UI components
β”‚   β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ package.json
β”‚   β”‚   └── tsconfig.json
β”‚   β”œβ”€β”€ config-eslint/          # Shared ESLint config
β”‚   β”‚   └── package.json
β”‚   β”œβ”€β”€ config-typescript/      # Shared TS config
β”‚   β”‚   └── package.json
β”‚   └── utils/                  # Shared utilities
β”‚       β”œβ”€β”€ src/
β”‚       └── package.json
β”œβ”€β”€ package.json                # Root package.json
β”œβ”€β”€ turbo.json                  # Turborepo config
β”œβ”€β”€ pnpm-workspace.yaml         # Workspace config (pnpm)
└── .gitignore

Root package.json

Code
JSON
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "scripts": {
    "build": "turbo build",
    "dev": "turbo dev",
    "lint": "turbo lint",
    "test": "turbo test",
    "clean": "turbo clean",
    "format": "prettier --write \"**/*.{ts,tsx,md}\""
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "prettier": "^3.0.0"
  },
  "packageManager": "pnpm@8.15.0"
}

turbo.json - Main configuration

Code
JSON
{
  "$schema": "https://turborepo.dev/schema.json",
  "globalDependencies": [".env"],
  "globalEnv": ["NODE_ENV", "CI"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"],
      "env": ["DATABASE_URL", "API_KEY"]
    },
    "lint": {
      "dependsOn": ["^lint"],
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"],
      "inputs": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"]
    },
    "clean": {
      "cache": false
    }
  }
}

Pipelines and Tasks

Understanding the Pipeline

The pipeline defines relationships between tasks in a monorepo:

Code
JSON
{
  "tasks": {
    "build": {
      // ^ means "build dependencies first"
      "dependsOn": ["^build"],
      // Output files to cache
      "outputs": ["dist/**", ".next/**"]
    },
    "test": {
      // Depends on local build (without ^)
      "dependsOn": ["build"],
      // Input files that affect the cache
      "inputs": ["src/**", "test/**"]
    },
    "deploy": {
      // Depends on build and test of the same package
      "dependsOn": ["build", "test"]
    }
  }
}

Dependency types

Code
JSON
{
  "tasks": {
    "build": {
      // ^build - build dependencies first (topological)
      "dependsOn": ["^build"]
    },
    "test": {
      // build - build THIS package first (same package)
      "dependsOn": ["build"]
    },
    "deploy": {
      // Both types together
      "dependsOn": ["^build", "test", "lint"]
    },
    "e2e": {
      // Dependency on a specific package
      "dependsOn": ["web#build"]
    }
  }
}

Outputs and caching

Code
JSON
{
  "tasks": {
    "build": {
      "outputs": [
        "dist/**",
        ".next/**",
        "!.next/cache/**",  // Exclude cache
        "build/**"
      ]
    },
    "lint": {
      // No outputs = task doesn't produce files
      "outputs": []
    },
    "test": {
      "outputs": ["coverage/**"]
    }
  }
}

Inputs - controlling cache invalidation

Code
JSON
{
  "tasks": {
    "build": {
      // Only these files affect the cache
      "inputs": [
        "src/**/*.ts",
        "src/**/*.tsx",
        "package.json",
        "tsconfig.json"
      ]
    },
    "lint": {
      "inputs": [
        "src/**/*.ts",
        "src/**/*.tsx",
        ".eslintrc.js",
        "package.json"
      ]
    },
    "test": {
      "inputs": [
        "$TURBO_DEFAULT$",  // Default inputs
        "jest.config.js",
        "test/**"
      ]
    }
  }
}

Environment Variables

Code
JSON
{
  // Global env - affects all tasks
  "globalEnv": ["CI", "NODE_ENV"],

  // Global dependencies - files affecting everything
  "globalDependencies": [".env", "tsconfig.base.json"],

  "tasks": {
    "build": {
      // Task-specific env
      "env": ["DATABASE_URL", "API_KEY", "NEXT_PUBLIC_*"],
      "passThroughEnv": ["AWS_SECRET_KEY"]  // Doesn't affect cache
    }
  }
}

Running Tasks

Basic commands

Code
Bash
# Run task in all packages
turbo build
turbo lint
turbo test

# Dev mode (no cache, persistent)
turbo dev

# Verbose output
turbo build --verbosity=2

# Dry run - show what will be executed
turbo build --dry-run

# Show dependency graph
turbo build --graph
turbo build --graph=graph.svg

Filtering packages

Code
Bash
# Only a specific package
turbo build --filter=web
turbo build --filter=@repo/ui

# Package and its dependencies
turbo build --filter=web...

# Package and its dependents (packages that depend on it)
turbo build --filter=...@repo/ui

# Packages in a specific folder
turbo build --filter="./apps/*"
turbo build --filter="./packages/*"

# Exclude packages
turbo build --filter="!docs"

# Combinations
turbo build --filter="web..." --filter="!docs"

# Only changed since last commit
turbo build --filter="[HEAD^1]"
turbo build --filter="...[main...HEAD]"

Advanced filters

Code
Bash
# Packages changed in PR
turbo build --filter="[origin/main...HEAD]"

# Packages depending on changed ui
turbo build --filter="...@repo/ui[HEAD^1]"

# All apps
turbo build --filter="./apps/**"

# Packages with a specific tag in package.json
turbo build --filter="@repo/*"

Execution options

Code
Bash
# Limit parallel tasks
turbo build --concurrency=4
turbo build --concurrency=50%  # 50% CPU cores

# Continue despite errors
turbo build --continue

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

# Output logs
turbo build --output-logs=full
turbo build --output-logs=hash-only
turbo build --output-logs=new-only
turbo build --output-logs=errors-only

Remote caching

Configuration with Vercel

Code
Bash
# Login to Vercel
npx turbo login

# Link your project
npx turbo link

# Now cache is shared!
turbo build

Verifying the remote cache

Code
Bash
# Write a run summary to a JSON file
turbo build --summarize

# At the end of the run the console shows:
#  Tasks:    7 successful, 7 total
# Cached:    5 cached, 7 total
#   Time:    1.2s
# Summary:    .turbo/runs/<id>.json

The split between local and remote hits appears only in that file: every task carries a cache field with local, remote and status, so that is where you confirm a result genuinely came from the shared store.

Self-hosted remote cache

Code
TypeScript
// Custom cache server (Express example)
import express from 'express'
import { createHash } from 'crypto'

const app = express()
const cache = new Map<string, Buffer>()

// GET artifact
app.get('/v8/artifacts/:hash', (req, res) => {
  const artifact = cache.get(req.params.hash)
  if (artifact) {
    res.send(artifact)
  } else {
    res.status(404).send('Not found')
  }
})

// PUT artifact
app.put('/v8/artifacts/:hash', express.raw({ limit: '50mb' }), (req, res) => {
  cache.set(req.params.hash, req.body)
  res.status(200).send('OK')
})

app.listen(3001)
Code
Bash
# Using a custom server
turbo build --api="http://localhost:3001" --token="secret"

CI/CD Configuration

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

on: [push, pull_request]

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

      - uses: pnpm/action-setup@v2
        with:
          version: 8

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

      - run: pnpm install

      - run: pnpm build
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Workspace Packages

UI component package

packages/ui/package.json
JSON
// packages/ui/package.json
{
  "name": "@repo/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./button": "./src/button.tsx",
    "./card": "./src/card.tsx",
    "./styles.css": "./styles.css"
  },
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
    "lint": "eslint src/",
    "test": "vitest run"
  },
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  },
  "devDependencies": {
    "@repo/config-typescript": "workspace:*",
    "tsup": "^8.0.0",
    "typescript": "^5.0.0"
  }
}
TSpackages/ui/src/index.ts
TypeScript
// packages/ui/src/index.ts
export { Button } from './button'
export { Card } from './card'
export { Input } from './input'
export type { ButtonProps, CardProps, InputProps } from './types'

Shared Config Packages

packages/config-eslint/package.json
JSON
// packages/config-eslint/package.json
{
  "name": "@repo/config-eslint",
  "version": "0.0.0",
  "private": true,
  "main": "index.js",
  "dependencies": {
    "@typescript-eslint/eslint-plugin": "^7.0.0",
    "@typescript-eslint/parser": "^7.0.0",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-react": "^7.0.0",
    "eslint-plugin-react-hooks": "^4.0.0"
  }
}
JSpackages/config-eslint/index.js
JavaScript
// packages/config-eslint/index.js
module.exports = {
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:react/recommended',
    'plugin:react-hooks/recommended',
    'prettier'
  ],
  parser: '@typescript-eslint/parser',
  plugins: ['@typescript-eslint', 'react'],
  rules: {
    'react/react-in-jsx-scope': 'off',
    '@typescript-eslint/no-unused-vars': 'warn'
  },
  settings: {
    react: { version: 'detect' }
  }
}

Using shared packages

apps/web/package.json
JSON
// apps/web/package.json
{
  "name": "web",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "lint": "eslint . --ext .ts,.tsx"
  },
  "dependencies": {
    "@repo/ui": "workspace:*",
    "@repo/utils": "workspace:*",
    "next": "^14.0.0",
    "react": "^18.0.0"
  },
  "devDependencies": {
    "@repo/config-eslint": "workspace:*",
    "@repo/config-typescript": "workspace:*"
  }
}
JSapps/web/.eslintrc.js
JavaScript
// apps/web/.eslintrc.js
module.exports = {
  extends: ['@repo/config-eslint'],
  parserOptions: {
    project: './tsconfig.json'
  }
}

TypeScript Config Sharing

packages/config-typescript/base.json
JSON
// packages/config-typescript/base.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true
  }
}
packages/config-typescript/nextjs.json
JSON
// packages/config-typescript/nextjs.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "lib": ["dom", "dom.iterable", "ES2022"],
    "module": "ESNext",
    "target": "ES2022",
    "jsx": "preserve",
    "plugins": [{ "name": "next" }]
  }
}
apps/web/tsconfig.json
JSON
// apps/web/tsconfig.json
{
  "extends": "@repo/config-typescript/nextjs.json",
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

Package-specific Tasks

Overriding tasks per-package

turbo.json
JSON
// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    // Override for a specific package
    "web#build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**"],
      "env": ["NEXT_PUBLIC_API_URL"]
    },
    // Override for docs
    "docs#build": {
      "dependsOn": ["^build"],
      "outputs": [".docusaurus/**", "build/**"]
    }
  }
}

Local turbo.json in a package

apps/web/turbo.json
JSON
// apps/web/turbo.json
{
  "$schema": "https://turborepo.dev/schema.json",
  "extends": ["//"],  // Inherit from root turbo.json
  "tasks": {
    "build": {
      // Override only specific options
      "env": ["ANALYZE", "NEXT_PUBLIC_SENTRY_DSN"]
    },
    "dev": {
      "persistent": true,
      "cache": false
    }
  }
}

Generators (Codegen)

Creating a generator

Code
Bash
# Create generators folder
mkdir -p turbo/generators
TSturbo/generators/config.ts
TypeScript
// turbo/generators/config.ts
import type { PlopTypes } from '@turbo/gen'

export default function generator(plop: PlopTypes.NodePlopAPI): void {
  // New package generator
  plop.setGenerator('package', {
    description: 'Create a new package',
    prompts: [
      {
        type: 'input',
        name: 'name',
        message: 'Package name:'
      },
      {
        type: 'list',
        name: 'type',
        message: 'Package type:',
        choices: ['lib', 'config', 'tool']
      }
    ],
    actions: [
      {
        type: 'add',
        path: 'packages/{{name}}/package.json',
        templateFile: 'templates/package.json.hbs'
      },
      {
        type: 'add',
        path: 'packages/{{name}}/src/index.ts',
        templateFile: 'templates/index.ts.hbs'
      },
      {
        type: 'add',
        path: 'packages/{{name}}/tsconfig.json',
        templateFile: 'templates/tsconfig.json.hbs'
      }
    ]
  })

  // New app generator
  plop.setGenerator('app', {
    description: 'Create a new application',
    prompts: [
      {
        type: 'input',
        name: 'name',
        message: 'Application name:'
      },
      {
        type: 'list',
        name: 'framework',
        message: 'Framework:',
        choices: ['next', 'remix', 'astro']
      }
    ],
    actions: (data) => {
      const actions: PlopTypes.ActionType[] = []

      if (data?.framework === 'next') {
        actions.push({
          type: 'addMany',
          destination: 'apps/{{name}}',
          templateFiles: 'templates/next/**/*',
          base: 'templates/next'
        })
      }

      return actions
    }
  })
}

Using the generator

Code
Bash
# List available generators
turbo gen

# Run a specific generator
turbo gen package
turbo gen app

# With answers to the generator prompts
turbo gen package --args my-lib lib

Templates

Code
HANDLEBARS
{{!-- turbo/generators/templates/package.json.hbs --}}
{
  "name": "@repo/{{name}}",
  "version": "0.0.0",
  "private": true,
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
    "lint": "eslint src/",
    "test": "vitest run"
  },
  "devDependencies": {
    "@repo/config-typescript": "workspace:*",
    "tsup": "^8.0.0",
    "typescript": "^5.0.0"
  }
}

Pruned Deployments

Docker with Pruned Monorepo

Code
DOCKERFILE
# Dockerfile
FROM node:20-alpine AS base

# Pruner stage
FROM base AS pruner
RUN npm install -g turbo
WORKDIR /app
COPY . .
RUN turbo prune web --docker

# Installer stage
FROM base AS installer
WORKDIR /app

# Install only needed dependencies
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install --frozen-lockfile

# Builder stage
FROM base AS builder
WORKDIR /app
COPY --from=installer /app/ .
COPY --from=pruner /app/out/full/ .

RUN corepack enable pnpm && pnpm turbo build --filter=web

# Runner stage
FROM base AS runner
WORKDIR /app

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs

COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public

CMD ["node", "apps/web/server.js"]

Pruning for CI

Code
Bash
# Create pruned subset
turbo prune web --docker

# Result in out/:
# out/
# β”œβ”€β”€ json/           # package.json files
# β”œβ”€β”€ full/           # Full source code
# └── pnpm-lock.yaml  # Pruned lockfile

Debugging and troubleshooting

Cache Debugging

Code
Bash
# Show why a task was executed
turbo build --summarize

# Show hash computation
turbo build --dry-run=json | jq

# Force cache miss
turbo build --force

# Clear the local cache (.turbo/cache by default)
rm -rf .turbo/cache

Task Graph

Code
Bash
# Generate dependency graph
turbo build --graph=graph.html
turbo build --graph=graph.svg
turbo build --graph=graph.mermaid

# Show in console
turbo build --graph

Verbose logging

Code
Bash
# Verbosity levels
turbo build --verbosity=0  # Quiet
turbo build --verbosity=1  # Default
turbo build --verbosity=2  # Verbose

# Show all logs
turbo build --output-logs=full

# Only errors
turbo build --output-logs=errors-only

Common Issues

Code
Bash
# Problem: Cache not working
# Solution: Check inputs and outputs

# Problem: Task runs despite no changes
# Check env variables in the summary written to .turbo/runs
turbo build --summarize
jq '.tasks[].environmentVariables' .turbo/runs/*.json

# Problem: Circular dependency
turbo build --graph  # Visualize dependencies

# Problem: Slow builds
turbo build --profile=profile.json
# Analyze in Chrome DevTools

Best practices

Package structure

Code
TEXT
packages/
β”œβ”€β”€ core/            # Business logic (no UI)
β”œβ”€β”€ ui/              # Shared React components
β”œβ”€β”€ hooks/           # Shared React hooks
β”œβ”€β”€ utils/           # Helpers, formatters
β”œβ”€β”€ types/           # Shared TypeScript types
β”œβ”€β”€ config-*/        # Shared configs (eslint, ts, prettier)
└── api-client/      # Generated API client

Naming Conventions

Code
JSON
// Use scope for packages
{
  "name": "@repo/ui",        // ok
  "name": "@mycompany/ui",   // ok
  "name": "ui",              // bad, conflicts with npm packages
}

Dependency Management

Code
JSON
// Root package.json - shared devDependencies
{
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.0.0",
    "prettier": "^3.0.0"
  }
}

// Package - only specific deps
{
  "dependencies": {
    "@repo/ui": "workspace:*"  // Internal dependency
  },
  "devDependencies": {
    "@repo/config-eslint": "workspace:*"
  }
}

Task Organization

Code
JSON
{
  "tasks": {
    // Build pipeline
    "build": { "dependsOn": ["^build"] },
    "build:production": { "dependsOn": ["^build", "lint", "test"] },

    // Dev
    "dev": { "cache": false, "persistent": true },

    // Quality
    "lint": { "outputs": [] },
    "lint:fix": { "outputs": [], "cache": false },
    "typecheck": { "outputs": [] },
    "test": { "dependsOn": ["build"] },
    "test:watch": { "cache": false, "persistent": true },

    // Maintenance
    "clean": { "cache": false }
  }
}

Integrations

Vercel deployment

vercel.json
JSON
// vercel.json
{
  "buildCommand": "pnpm turbo build --filter=web",
  "installCommand": "pnpm install",
  "framework": "nextjs",
  "outputDirectory": "apps/web/.next"
}

GitHub Actions

.github/workflows/ci.yml
YAML
# .github/workflows/ci.yml
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  # Needed for --filter=[HEAD^1]

      - 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

      - name: Build
        run: pnpm turbo build

      - name: Test
        run: pnpm turbo test

      - name: Lint
        run: pnpm turbo lint

Changesets (versioning)

Code
Bash
npm install @changesets/cli -D
npx changeset init
.changeset/config.json
JSON
// .changeset/config.json
{
  "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [],
  "linked": [],
  "access": "restricted",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@repo/config-*"]
}

FAQ - frequently asked questions

Does Turborepo require Vercel?

No! Turborepo is open-source and works without Vercel. Remote caching can be self-hosted or you can use Vercel as a convenient option.

How do I migrate from Lerna/Nx?

Turborepo is additive - you can add turbo.json to an existing monorepo without removing other tools. Gradually move tasks to Turborepo.

Can I use npm instead of pnpm?

Yes! Turborepo works with npm workspaces, pnpm workspaces, and yarn workspaces. pnpm is recommended for performance reasons.

How do I debug caching issues?

Use turbo build --summarize to see what affects the cache hash. Check env variables, inputs, and outputs in turbo.json.

Does Turborepo support monorepos with different languages?

Turborepo is optimized for JavaScript/TypeScript but can orchestrate any tasks. For polyglot monorepos, consider Nx or Bazel.

What's the difference between dependsOn: ["^build"] and dependsOn: ["build"]?

  • ^build - build dependencies first (packages we depend on)
  • build - build in the same package first

How to optimize CI build time?

  1. Enable remote caching
  2. Use --filter for changed packages
  3. Parallel jobs in CI
  4. Optimize inputs/outputs

Do I have to use Vercel's remote cache?

No. The mechanism is open and independent cache server implementations exist that you can host yourself or back with your own object storage. The convenience of a managed service is real but not a condition of using the tool.

Remote caching and why it changes the most

A local cache speeds up one person's work. A shared cache speeds up the whole team and the build server, and that is a qualitative rather than quantitative difference.

The mechanism is straightforward: a task result goes into a shared store, described by the same key used locally. When somebody else, or the build server, runs the same task on the same input, it pulls the finished result instead of recomputing it.

The largest gain appears in the continuous integration pipeline, for instance around deployments through Vercel, for a reason easy to forget. A build server usually starts from a clean state, so without a shared cache it rebuilds everything on every pull request, even when one file in one package changed. With a shared cache it pulls results produced earlier, including on somebody's laptop.

The second gain concerns a new person joining. A first run after cloning the repository can take fifteen minutes, and with a shared cache it reduces to downloading finished artefacts.

Two things deserve thought before switching this on, though. The first is trust: the cache holds build artefacts, so anyone who can write to it can inject somebody else's output into your build. On a public repository or with broad access, restrict writes to the build server and leave the rest of the team with read access.

The second is transfer cost. With large artefacts, downloading a result can be slower than building it, particularly on a poor connection. If times did not improve after enabling remote caching, check the size of what lands in the store before looking elsewhere.

Common mistakes

The first is undeclared environment variables, described above. It is the most dangerous of these, because it does not manifest as a failure but as silently handing back a result built for a different environment.

The second is treating this as a replacement for a package manager. Workspaces are defined by the package manager, and the tool merely reads a dependency graph from them. Missing workspace configuration means an empty graph, and then there is nothing to parallelise, which is the first thing to check when parallelism does not happen.

The third is non deterministic tasks. If a build embeds a timestamp or a random identifier in its output, two runs on the same input produce different outputs. The cache still works but stops meaning anything, since artefact comparisons no longer signify.

The fourth is splitting into packages too finely. A monorepo with fifty packages of three files each spends more time servicing the graph than doing work. Split along boundaries that genuinely matter for deployment and code ownership.

The fifth is forgetting that a cache does not replace tests, including those written in Vitest. A skipped task is a task nobody ran, so if a test catches a problem depending on something outside the declared inputs, it simply will not execute. Knowing exactly what goes into the key is the condition for trusting the result.

Documentation lives on the Turborepo site, and the source in the project repository.