yigityalim/x/github/hire/share
Back to Handbooks

Monorepo Architecture with Turborepo

An opinionated production reference for Turborepo monorepos — workspace structure, internal packages, task graph design, caching, environment variables, CI/CD, and deploy strategies. Based on Turborepo 2.9. Not a tutorial.

Last updated: 2026-04-20

Tech Stack

TurborepoTypeScriptNext.jsBunVercelGitHub Actions

Links

GitHub
PreviousSupabase Production GuideNextNext.js App Router Handbook
© 2026 Yiğit Yalım. All rights reserved.
/

This is the reference I built from running Turborepo across every project since 2022 — from a two-app portfolio monorepo to a 600K LOC AI platform with 10 apps and 14 packages. The patterns here are what survived contact with real codebases.

Every section answers one question: how do I actually design this for production?

Covers Turborepo 2.9 (March 2026). Key new features: up to 96% faster Time to First Task, turbo query stable, turbo.jsonc support, OpenTelemetry (experimental), structured logging (--json, --log-file), Future Flags for 3.0 migration.


Table of Contents

  • 1. Why a Monorepo?
  • 2. Workspace Structure
    • Directory Layout
    • Workspace Configuration
    • Root package.json
    • .gitignore additions
  • 3. Internal Packages
    • Three Strategies
    • Package Naming
    • Anatomy of a Good Package package.json
    • Installing Internal Packages
    • The Package Graph
  • 4. Configuring Tasks
    • turbo.json Anatomy
    • dependsOn Syntax
    • Task Types
    • Package Configurations
    • Sidecar Tasks
  • 5. Caching
    • How Caching Works
    • Local vs Remote Cache
    • Configuring Outputs
    • Configuring Inputs
    • Remote Cache Setup
    • Cache Artifact Signing
    • Debugging Cache Misses
    • When NOT to Cache
  • 6. Environment Variables
    • The Cache Problem
    • Declaring Environment Variables
    • Global Dependencies
    • Framework Auto-Detection
    • The CI Env Var Problem
    • ESLint / Biome Rule
  • 7. Development Workflow
    • Running Tasks
    • Watch Mode
    • Terminal UI
    • turbo query (Stable in 2.9)
    • Devtools
    • Upgrading
  • 8. CI/CD
    • GitHub Actions — Basic Setup
    • Affected-Only CI
    • Structured Logs in CI (2.9+)
    • Self-Hosted Remote Cache (GitHub Actions Cache Fallback)
    • CI Strategy: Lint/Test All, Build Affected
    • Docker with Turborepo Prune
  • 9. Shared Config Packages
    • TypeScript Config
    • ESLint Config
    • Prettier Config
  • 10. Deploy Strategies
    • Independent App Deploys
    • Vercel Monorepo Deploy
    • Conditional Docker Build
  • 11. Tooling Reference
    • turbo.json Key Reference
    • CLI Reference
    • Filter Syntax
    • Deprecated Features to Avoid (3.0 Prep)
    • My create-turbo-stack Template
  • Appendix: Quick Reference
    • Repository Checklist
    • Common Cache Miss Reasons

1. Why a Monorepo?

A monorepo earns its complexity only when you have code that should be shared across multiple applications. If your apps evolve independently with minimal shared code, separate repositories are simpler.

When to use a monorepo:

✅ Multiple apps share UI components, hooks, types, or utilities
✅ You want one PR to update a shared package and all consumers simultaneously
✅ You want unified TypeScript, ESLint, and Prettier configuration
✅ You need atomic cross-package changes

❌ Apps are truly independent (different teams, different deployment cycles)
❌ You have one app and no plans for more
❌ The shared code is better as an npm package

The monorepo tax: initial setup complexity, CI configuration overhead, and occasionally surprising cache behavior. These are real costs — be honest about whether the benefits outweigh them for your situation.


2. Workspace Structure

Directory Layout

my-monorepo/
├── apps/              # Applications — deployed, not installed into other packages
│   ├── web/           # Next.js frontend
│   ├── docs/          # Documentation site
│   └── api/           # Backend API
├── packages/          # Libraries and tooling — installed into apps and each other
│   ├── ui/            # Shared React components
│   ├── config-ts/     # Shared TypeScript config
│   ├── config-eslint/ # Shared ESLint config
│   └── utils/         # Shared utilities
├── package.json       # Root — workspace definition + devDependencies for repo management
├── turbo.json         # Task pipeline definition
└── pnpm-workspace.yaml (or bun.lockb / .npmrc for npm/yarn)

The apps/ vs packages/ split is a convention, not a requirement. What matters: apps are deployed and not imported by other packages; packages are libraries imported by apps and other packages.

Workspace Configuration

pnpm (pnpm-workspace.yaml):

packages:
  - "apps/*"
  - "packages/*"

Bun (package.json):

{
  "workspaces": ["apps/*", "packages/*"]
}

npm/yarn (package.json):

{
  "workspaces": ["apps/*", "packages/*"]
}

Root package.json

The root is for repository management tools only — not application dependencies:

{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo build",
    "dev": "turbo dev",
    "lint": "turbo lint",
    "check-types": "turbo check-types",
    "test": "turbo test",
    "format": "prettier --write \"**/*.{ts,tsx,md}\""
  },
  "devDependencies": {
    "turbo": "latest",
    "prettier": "^3.0.0"
  },
  "packageManager": "pnpm@9.0.0"
}

The packageManager field is important — Turborepo uses it to detect the package manager and stabilize lockfile parsing. Without it, cache invalidation can be unpredictable.

.gitignore additions

.turbo
node_modules
dist
.next
out

3. Internal Packages

Three Strategies

Internal packages (libraries inside your workspace) can be structured three ways:

1. Just-in-Time (JIT) / Transpiled by Consumer

No build step. The consumer's bundler (Next.js, Vite, etc.) transpiles the TypeScript directly.

// packages/ui/package.json
{
  "name": "@repo/ui",
  "exports": {
    ".": "./src/index.tsx"  // points directly to source
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "react": "^19.0.0"
  }
}

Pros: Zero build step, fastest iteration, no dist/ to worry about.

Cons: Only works when every consumer uses a bundler that understands TypeScript. Cannot be cached by Turborepo (no build outputs). Cannot use compilerOptions.paths in TypeScript.

2. Compiled Package

Has its own build step. Turborepo can cache the build output.

// packages/utils/package.json
{
  "name": "@repo/utils",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "check-types": "tsc --noEmit"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}
// packages/utils/tsconfig.json
{
  "extends": "@repo/config-ts/base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "declarationMap": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Pros: Works everywhere (no bundler assumption), cacheable, explicit API surface via exports.

Cons: Build step required, slightly more configuration.

3. Published Package

Same as compiled, but also published to npm. For code that needs to be used outside the monorepo.

Package Naming

Use a namespace prefix to avoid npm registry conflicts. @repo/ is an unused, unclaimable namespace — ideal for internal packages that won't be published.

@repo/ui
@repo/utils
@repo/config-ts
@repo/config-eslint

Anatomy of a Good Package package.json

{
  "name": "@repo/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./button": {
      "types": "./dist/button.d.ts",
      "default": "./dist/button.js"
    }
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "check-types": "tsc --noEmit",
    "lint": "eslint src/"
  },
  "peerDependencies": {
    "react": "^19.0.0"
  },
  "devDependencies": {
    "@repo/config-ts": "workspace:*",
    "@repo/config-eslint": "workspace:*",
    "typescript": "^5.0.0"
  }
}

Installing Internal Packages

// apps/web/package.json
{
  "dependencies": {
    "@repo/ui": "workspace:*",
    "@repo/utils": "workspace:*"
  }
}

workspace:* means "use whatever version is in this workspace." The package manager symlinks the package instead of downloading it.

The Package Graph

Turborepo automatically builds a Package Graph from your dependencies. This is the foundation for:

  • Knowing which packages to rebuild when a dependency changes
  • Topological ordering of tasks (^build means "build all my dependencies first")
  • --affected filtering in CI
apps/web → depends on → packages/ui → depends on → packages/utils

If utils changes, Turborepo knows ui and web must be rebuilt.


4. Configuring Tasks

turbo.json Anatomy

// turbo.json (or turbo.jsonc for comments)
{
  "$schema": "https://turborepo.dev/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],      // build all dependencies first
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "dev": {
      "cache": false,               // never cache dev servers
      "persistent": true            // long-running process
    },
    "lint": {
      "dependsOn": ["^build"]       // lint after deps are built (for type imports)
    },
    "check-types": {
      "dependsOn": ["^check-types"]
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    },
    "test:watch": {
      "cache": false,
      "persistent": true
    }
  }
}

dependsOn Syntax

"dependsOn": ["^build"]    → run build in all dependency packages first (topological)
"dependsOn": ["build"]     → run build in the SAME package first
"dependsOn": []            → no dependencies, run whenever
"dependsOn": ["lint", "^build"] → run this package's lint AND all deps' build first

^ is the topological operator — "all packages I depend on." Without ^, it means "this package."

Task Types

{
  "tasks": {
    // ✅ Cacheable build — has outputs to cache
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
 
    // ✅ Cacheable check — no file outputs, but logs are cached
    "check-types": {
      "dependsOn": ["^check-types"]
    },
 
    // ✅ Cacheable lint
    "lint": {},
 
    // ❌ Not cacheable — different result every run
    "dev": {
      "cache": false,
      "persistent": true
    },
 
    // ❌ Not cacheable — side effects (DB mutations, API calls)
    "db:push": {
      "cache": false
    }
  }
}

Package Configurations

For packages that need custom task config, create a turbo.json in that package:

// apps/web/turbo.json
{
  "extends": ["//"],  // inherit from root
  "tasks": {
    "build": {
      "outputs": [".next/**", "!.next/cache/**"]  // only Next.js outputs, not dist/**
    }
  }
}
// apps/docs/turbo.json
{
  "extends": ["//"],
  "tasks": {
    "build": {
      "outputs": ["out/**"]  // static export
    }
  }
}

In Turborepo 2.7+, you can extend from other packages too:

{
  "extends": ["//", "@repo/config-turbo"]
}

Sidecar Tasks

For tasks that must run alongside other tasks (like a dev database):

{
  "tasks": {
    "dev": {
      "cache": false,
      "persistent": true,
      "with": ["db:start"]  // run db:start as a sidecar
    },
    "db:start": {
      "cache": false,
      "persistent": true
    }
  }
}

5. Caching

How Caching Works

Turborepo hashes:

  • Source files in the package (all non-gitignored files by default)
  • inputs glob patterns you specify
  • Environment variables listed in env
  • Global dependencies (globalDependencies)
  • dependsOn outputs from dependency packages
  • The task definition itself

If the hash matches a cached entry: restore outputs and replay logs instantly. If not: run the task, then cache results.

Local vs Remote Cache

Local cache  → .turbo/cache (per machine, per developer)
Remote cache → Vercel / self-hosted (shared across team + CI)

Remote cache is where the biggest wins come from — a colleague's build is your cache hit.

Configuring Outputs

Always declare outputs. If you don't, Turborepo can't restore them:

{
  "tasks": {
    "build": {
      "outputs": [
        "dist/**",           // compiled output
        ".next/**",          // Next.js build
        "!.next/cache/**"    // exclude Next.js internal cache (too large)
      ]
    },
    "check-types": {
      // no outputs — only logs are cached
    },
    "test": {
      "outputs": ["coverage/**"]  // test coverage report
    }
  }
}

Configuring Inputs

By default, Turborepo uses all non-gitignored files in the package as inputs. Override with inputs to be more precise:

{
  "tasks": {
    "check-types": {
      "inputs": ["src/**/*.ts", "src/**/*.tsx", "tsconfig.json"]
      // Only TypeScript files affect the type check hash
      // Changing README.md won't cause a cache miss
    },
    "lint": {
      "inputs": ["src/**/*.ts", "src/**/*.tsx", ".eslintrc.js"]
    },
    "test": {
      "inputs": ["src/**/*.ts", "src/**/*.tsx", "**/*.test.ts", "jest.config.js"]
    }
  }
}

Remote Cache Setup

Vercel (zero config when deployed on Vercel):

# Login and link
turbo login
turbo link

Vercel from CI:

# Set in CI environment
TURBO_TOKEN=your-vercel-token
TURBO_TEAM=your-team-slug

Self-hosted (ducktors/turborepo-remote-cache):

# CI environment variables
TURBO_API=https://your-cache-server.com
TURBO_TOKEN=your-secret-token
TURBO_TEAM=your-team

New --cache flag (replaces deprecated flags in 2.9):

# Read+write local, read-only remote
turbo build --cache=local:rw,remote:r
 
# Remote only
turbo build --cache=remote:rw
 
# Disable cache entirely (replaces --no-cache)
turbo build --cache=local:r,remote:r

Cache Artifact Signing

For extra security, sign artifacts with HMAC:

// turbo.json
{
  "remoteCache": {
    "signature": true
  },
  "futureFlags": {
    "longerSignatureKey": true  // enforce 32-byte minimum key (3.0 default)
  }
}
# Environment variable
TURBO_REMOTE_CACHE_SIGNATURE_KEY=your-32-byte-minimum-secret-key

Debugging Cache Misses

# See why a task missed cache
turbo build --summarize
# Outputs .turbo/runs/*.json with full hash inputs
 
# See what turbo would run without running it
turbo build --dry-run
turbo build --dry-run=json | jq '.tasks[] | select(.cache.status == "MISS")'
 
# Force re-run ignoring cache (writes to cache after)
turbo build --force
 
# Visualize what would run
turbo build --graph
 
# OpenTelemetry (2.9+, experimental)
# turbo.json: { "futureFlags": { "experimentalObservability": true } }
# Sends metrics to your OTLP backend

When NOT to Cache

❌ Tasks with network side effects (API calls, DB mutations)
❌ Dev servers (always use cache: false, persistent: true)
❌ Tasks faster than cache overhead (sub-100ms tasks)
❌ Tasks with enormous outputs (>500MB Docker images)

6. Environment Variables

The Cache Problem

Environment variables are part of the cache hash. If an env var changes, the cache misses. If you forget to declare env vars, you get stale caches.

If DATABASE_URL changes → build should miss cache (it affects the bundle)
If CI_JOB_ID changes → build should NOT miss cache (irrelevant to output)

Declaring Environment Variables

{
  "tasks": {
    "build": {
      "env": [
        "DATABASE_URL",        // affects build → include
        "NEXT_PUBLIC_APP_URL", // public env, affects bundle → include
        "NODE_ENV"             // affects build behavior → include
      ],
      "passThroughEnv": [
        "CI",                  // needed at runtime, doesn't affect output → pass through
        "VERCEL_URL"           // deployment URL, varies per deploy → pass through
      ]
    }
  }
}

env: changes to these invalidate the cache. passThroughEnv: passed to the task but don't affect the cache hash.

Global Dependencies

Variables and files that affect ALL tasks:

{
  "globalEnv": [
    "TURBO_TEAM",
    "NODE_ENV"
  ],
  "globalDependencies": [
    ".env",          // changing .env invalidates all caches
    "tsconfig.json"  // root tsconfig changes affect everything
  ]
}

Framework Auto-Detection

Turborepo auto-detects public env vars for known frameworks:

Next.js  → NEXT_PUBLIC_* automatically included in hash
Vite     → VITE_* automatically included
CRA      → REACT_APP_* automatically included

You don't need to declare these manually.

The CI Env Var Problem

CI systems set variables like CI_JOB_ID, GITHUB_SHA, BUILD_NUMBER that change every run. Include them in the hash and you'll never hit cache.

Use TURBO_CI_VENDOR_ENV_KEY to exclude CI-specific prefixes from auto-inference:

# Exclude GITHUB_* from Turborepo's automatic env inference
TURBO_CI_VENDOR_ENV_KEY=GITHUB_

ESLint / Biome Rule

Turborepo 2.7+ ships a Biome rule that catches undeclared env vars:

// biome.json
{
  "linter": {
    "rules": {
      "nursery": {
        "noUndeclaredEnvVars": "warn"  // reads turbo.json automatically
      }
    }
  }
}

For ESLint:

bun add -D eslint-plugin-turbo
// eslint.config.mjs
import turboPlugin from 'eslint-plugin-turbo'
 
export default [
  {
    plugins: { turbo: turboPlugin },
    rules: {
      'turbo/no-undeclared-env-vars': 'error',
    },
  },
]

7. Development Workflow

Running Tasks

# Run a task across all packages
turbo build
turbo dev
turbo lint
 
# Run only for specific packages
turbo build --filter=web
turbo build --filter=@repo/ui
turbo build --filter=./apps/web
 
# Run for a package and all its dependencies
turbo build --filter=web...
 
# Run for packages that changed vs main
turbo build --affected
 
# Run for packages in a directory
turbo build --filter="./apps/*"

Watch Mode

# Re-run tasks when files change
turbo watch build       # re-build when sources change
turbo watch check-types # re-type-check on save

Watch mode re-runs only the tasks in packages where files changed.

Terminal UI

Turborepo 2.0+ ships with an interactive TUI:

turbo dev  # automatically uses TUI
 
# Force specific UI
turbo build --ui=tui     # interactive
turbo build --ui=stream  # traditional log streaming (good for CI)

In the TUI: use arrow keys to navigate packages, press Enter to focus a package's logs, Ctrl+Z to exit focus.

turbo query (Stable in 2.9)

Query your monorepo structure with GraphQL or shorthands:

# Open interactive GraphiQL playground
turbo query
 
# Which packages are affected by current changes?
turbo query affected
 
# Which packages would be affected if I changed packages/ui?
turbo query affected --packages
 
# Which tasks would run if I changed packages/ui?
turbo query affected --tasks build
 
# List all packages
turbo query ls
 
# Details for a specific package
turbo query ls web
 
# List only affected packages
turbo query ls --affected

Devtools

# Launch the visual package/task graph explorer
turbo devtools

Visualize your Package Graph (what depends on what) and Task Graph (what runs when). Hot-reloads as you change configuration.

Upgrading

# Automated upgrade with codemod
bunx @turbo/codemod migrate

Applies breaking change migrations automatically. Run this whenever you bump the turbo version.


8. CI/CD

GitHub Actions — Basic Setup

# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: ["main"]
  pull_request:
    types: [opened, synchronize]
 
jobs:
  build-and-test:
    name: Build and Test
    runs-on: ubuntu-latest
    timeout-minutes: 15
 
    env:
      # Vercel Remote Cache
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 2  # needed for --affected comparison
 
      - uses: pnpm/action-setup@v4
        with:
          version: 9
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Lint + Type Check
        run: pnpm turbo lint check-types
 
      - name: Test
        run: pnpm turbo test
 
      - name: Build
        run: pnpm turbo build

Affected-Only CI

Only run tasks for packages that changed in the PR:

- name: Run affected tasks
  run: pnpm turbo build test --affected
  # Turborepo auto-detects GITHUB_BASE_REF in GitHub Actions
  # Compares PR head to PR base branch
  # Falls back to running everything if history is unavailable (shallow clone)

Important: --affected needs git history. Use fetch-depth: 0 for full history, or ensure the base ref is available:

- name: Checkout
  uses: actions/checkout@v4
  with:
    fetch-depth: 0  # full history for accurate --affected

Structured Logs in CI (2.9+)

- name: Build with structured logs
  run: pnpm turbo build --log-file=.turbo/build-log.json
 
- name: Upload build log
  uses: actions/upload-artifact@v4
  if: always()
  with:
    name: build-log
    path: .turbo/build-log.json

Self-Hosted Remote Cache (GitHub Actions Cache Fallback)

When you don't have Vercel Remote Cache, use GitHub Actions cache as a fallback:

- name: Cache turbo build setup
  uses: actions/cache@v4
  with:
    path: .turbo
    key: ${{ runner.os }}-turbo-${{ github.sha }}
    restore-keys: |
      ${{ runner.os }}-turbo-

Note: this per-machine cache is much less effective than a shared remote cache. Use Vercel Remote Cache or a self-hosted server for team-wide sharing.

CI Strategy: Lint/Test All, Build Affected

jobs:
  quality:
    name: Lint + Types + Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: pnpm install --frozen-lockfile
      # Run quality checks on everything — remote cache handles the rest
      - run: pnpm turbo lint check-types test
 
  deploy:
    name: Build and Deploy
    runs-on: ubuntu-latest
    needs: quality
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: pnpm install --frozen-lockfile
      # Only build changed apps + their dependencies
      - run: pnpm turbo build --affected

Docker with Turborepo Prune

turbo prune creates a sparse workspace with only what one app needs:

# Create pruned workspace for the web app
turbo prune web --docker

Output:

out/
├── json/          # package.json files only (for dependency install layer)
│   ├── package.json
│   └── apps/web/package.json
└── full/          # full source code
    ├── package.json
    ├── turbo.json
    └── apps/web/
FROM node:20-alpine AS base
 
# Install dependencies from pruned workspace
FROM base AS installer
WORKDIR /app
COPY out/json/ .
RUN pnpm install --frozen-lockfile
 
# Build from full source
FROM base AS builder
WORKDIR /app
COPY --from=installer /app/node_modules ./node_modules
COPY out/full/ .
 
# Remote cache credentials
ARG TURBO_TOKEN
ARG TURBO_TEAM
ENV TURBO_TOKEN=$TURBO_TOKEN
ENV TURBO_TEAM=$TURBO_TEAM
 
RUN pnpm turbo build --filter=web
 
# Production image
FROM base AS runner
WORKDIR /app
COPY --from=builder /app/apps/web/.next/standalone ./
EXPOSE 3000
CMD ["node", "server.js"]

9. Shared Config Packages

The highest-leverage thing in a monorepo: write config once, share everywhere.

TypeScript Config

packages/
└── config-ts/
    ├── package.json
    ├── base.json
    ├── nextjs.json
    └── react-library.json
// packages/config-ts/package.json
{
  "name": "@repo/config-ts",
  "version": "0.0.0",
  "private": true,
  "exports": {
    "./base": "./base.json",
    "./nextjs": "./nextjs.json",
    "./react-library": "./react-library.json"
  }
}
// packages/config-ts/base.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "skipLibCheck": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}
// packages/config-ts/nextjs.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "plugins": [{ "name": "next" }],
    "jsx": "preserve",
    "allowJs": true,
    "incremental": true,
    "noEmit": true
  }
}
// packages/config-ts/react-library.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "jsx": "react-jsx",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

Usage in a Next.js app:

// apps/web/tsconfig.json
{
  "extends": "@repo/config-ts/nextjs",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

ESLint Config

packages/
└── config-eslint/
    ├── package.json
    ├── base.js         # shared rules
    ├── next.js         # Next.js-specific
    └── react-internal.js # for internal packages
// packages/config-eslint/base.js
import js from "@eslint/js"
import turboPlugin from "eslint-plugin-turbo"
import tsPlugin from "@typescript-eslint/eslint-plugin"
import tsParser from "@typescript-eslint/parser"
 
export default [
  js.configs.recommended,
  {
    plugins: {
      turbo: turboPlugin,
      "@typescript-eslint": tsPlugin,
    },
    languageOptions: {
      parser: tsParser,
    },
    rules: {
      "turbo/no-undeclared-env-vars": "error",
      "@typescript-eslint/no-unused-vars": "error",
      "@typescript-eslint/no-explicit-any": "warn",
    },
  },
]
// apps/web/eslint.config.mjs
import baseConfig from "@repo/config-eslint/next"
 
export default [...baseConfig]

Prettier Config

// packages/config-prettier/package.json
{
  "name": "@repo/config-prettier",
  "version": "0.0.0",
  "private": true,
  "main": "index.json"
}
// packages/config-prettier/index.json
{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100
}
// apps/web/package.json
{
  "prettier": "@repo/config-prettier"
}

10. Deploy Strategies

Independent App Deploys

Each app deploys independently when its package or dependencies change:

# .github/workflows/deploy-web.yml
name: Deploy Web
 
on:
  push:
    branches: [main]
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
 
      - run: pnpm install --frozen-lockfile
 
      # Only deploy if web or its dependencies changed
      - name: Check if web affected
        id: affected
        run: |
          AFFECTED=$(pnpm turbo query affected --packages | grep '"web"' || true)
          echo "deploy=$([[ -n "$AFFECTED" ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT
 
      - name: Build web
        if: steps.affected.outputs.deploy == 'true'
        run: pnpm turbo build --filter=web
 
      - name: Deploy to Vercel
        if: steps.affected.outputs.deploy == 'true'
        run: vercel deploy --prod
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}

Vercel Monorepo Deploy

Vercel natively understands Turborepo. Set the Root Directory for each project in Vercel:

Project: web    → Root Directory: apps/web
Project: docs   → Root Directory: apps/docs
Project: api    → Root Directory: apps/api

Vercel automatically uses Turborepo Remote Cache with zero configuration for all projects in the same team.

Conditional Docker Build

- name: Build and push Docker image
  run: |
    # Only build if the api changed
    if pnpm turbo query affected --packages | grep -q '"api"'; then
      docker build -f apps/api/Dockerfile \
        --build-arg TURBO_TOKEN=${{ secrets.TURBO_TOKEN }} \
        --build-arg TURBO_TEAM=${{ vars.TURBO_TEAM }} \
        -t my-api:${{ github.sha }} .
      docker push my-api:${{ github.sha }}
    fi

11. Tooling Reference

turbo.json Key Reference

{
  "$schema": "https://turborepo.dev/schema.json",
 
  // Global settings
  "globalDependencies": [".env", "tsconfig.json"],
  "globalEnv": ["NODE_ENV", "DATABASE_URL"],
  "globalPassThroughEnv": ["CI", "GITHUB_SHA"],
 
  // Task definitions
  "tasks": {
    "build": {
      "dependsOn": ["^build"],      // topological: run in all deps first
      "outputs": ["dist/**"],        // files to cache
      "inputs": ["src/**"],          // files that affect the hash (default: all)
      "env": ["MY_VAR"],             // env vars that affect the hash
      "passThroughEnv": ["CI"],      // env vars passed through but not hashed
      "cache": true,                 // default: true
      "outputLogs": "full"           // full | hash-only | new-only | errors-only | none
    },
    "dev": {
      "cache": false,
      "persistent": true,            // long-running process
      "with": ["db:start"]           // sidecar tasks
    }
  },
 
  // Remote cache
  "remoteCache": {
    "signature": true
  },
 
  // UI
  "ui": "tui",                       // tui | stream
 
  // Future flags for 3.0 migration
  "futureFlags": {
    "globalConfiguration": true,
    "affectedUsingTaskInputs": true,
    "watchUsingTaskInputs": true,
    "longerSignatureKey": true,
    "experimentalObservability": true
  },
 
  // OpenTelemetry (experimental, gated by futureFlag)
  "experimentalObservability": {
    "otel": {
      "enabled": true,
      "endpoint": "http://otel-collector.example.com:4317",
      "protocol": "grpc"
    }
  }
}

CLI Reference

# Run tasks
turbo <task>                      # run task in all packages
turbo <task> --filter=<pkg>       # run in specific package
turbo <task> --affected           # run in changed packages only
turbo <task> --force              # force re-run, ignore cache
turbo <task> --dry-run            # see what would run
turbo <task> --dry-run=json       # machine-readable dry run
turbo <task> --graph              # output task graph
turbo <task> --summarize          # output run summary with hash details
turbo <task> --json               # stream NDJSON logs (2.9+)
turbo <task> --log-file=path.json # write structured logs to file (2.9+)
 
# Cache control (2.9+, replaces deprecated flags)
turbo build --cache=local:rw,remote:r   # local read+write, remote read-only
turbo build --cache=remote:rw           # remote only
turbo build --cache=local:r,remote:r    # read-only (no cache writes)
 
# Query (stable in 2.9)
turbo query                       # open GraphiQL playground
turbo query affected               # show affected tasks
turbo query affected --packages    # show affected packages
turbo query affected --tasks build # affected build tasks
turbo query ls                     # list all packages
turbo query ls web                 # details for web package
turbo query ls --affected          # list affected packages
 
# Remote cache
turbo login                        # authenticate with Vercel
turbo link                         # link to Vercel Remote Cache
turbo logout
 
# Devtools
turbo devtools                     # open visual graph explorer
 
# Upgrade
bunx @turbo/codemod migrate        # automated upgrade with codemod

Filter Syntax

# By name
--filter=web
--filter=@repo/ui
 
# By path
--filter=./apps/web
--filter="./apps/*"
 
# Include dependents (packages that depend on the target)
--filter=web...          # web and its dependents
--filter=...web          # web and its dependencies
 
# By git changes
--filter="[HEAD^1]"      # changed in last commit
--filter="[main...HEAD]" # changed since branching from main
 
# Affected (smart git detection)
--affected               # auto-detects base ref

Deprecated Features to Avoid (3.0 Prep)

# Deprecated → Replacement
turbo-ignore          → turbo query affected
turbo scan            → obsolete
--parallel            → use persistent + with in turbo.json
--no-cache            → --cache=local:r,remote:r
--remote-only         → --cache=remote:rw
TURBO_REMOTE_ONLY     → --cache=remote:rw
TURBO_REMOTE_CACHE_READ_ONLY → --cache=local:rw,remote:r

My create-turbo-stack Template

My open-source scaffolding CLI generates Turborepo monorepos pre-configured with this setup:

bunx create-turbo-stack

Generates:

  • apps/web — Next.js 16 + Tailwind CSS
  • packages/ui — JIT React components
  • packages/config-ts — Shared TypeScript config
  • packages/config-eslint — Shared ESLint config
  • Pre-configured turbo.json with build, dev, lint, check-types, test
  • GitHub Actions CI workflow with Remote Cache
  • pnpm or Bun workspace support

Appendix: Quick Reference

Repository Checklist

Structure
  ☐ apps/ for deployable applications
  ☐ packages/ for shared libraries and config
  ☐ packageManager field in root package.json
  ☐ .turbo in .gitignore
  ☐ Namespace prefix for internal packages (@repo/)

Tasks
  ☐ build has dependsOn: ["^build"] for libraries
  ☐ build declares outputs
  ☐ dev has cache: false, persistent: true
  ☐ check-types task in every TypeScript package
  ☐ Package configs (turbo.json per package) for app-specific outputs

Caching
  ☐ env vars declared in tasks.env
  ☐ No CI env vars causing cache misses
  ☐ Remote cache configured for team
  ☐ Cache artifact signing enabled (production)

CI
  ☐ fetch-depth: 0 for --affected to work
  ☐ TURBO_TOKEN and TURBO_TEAM set as CI secrets
  ☐ --affected for selective builds
  ☐ --ui=stream for CI-friendly output

Common Cache Miss Reasons

1. Env var not declared in turbo.json → add to env[]
2. CI env var included in hash → add to passThroughEnv[] or exclude with TURBO_CI_VENDOR_ENV_KEY
3. outputs not declared → add to outputs[]
4. globalDependencies changed (lockfile, tsconfig) → expected
5. dependency package changed → expected (topological propagation)

Last updated: April 2026. Covers Turborepo 2.9 (March 2026). Key features: 96% faster Time to First Task, turbo query stable, turbo.jsonc, OpenTelemetry (experimental), structured logging, Future Flags for 3.0 migration.