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.
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.
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:
Do
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
Don't
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.
# 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.
The packageManager field is important — Turborepo uses it to detect the package manager and stabilize lockfile parsing. Without it, cache invalidation can be unpredictable.
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.
Use a namespace prefix to avoid npm registry conflicts. @repo/ is an unused, unclaimable namespace — ideal for internal packages that won't be published.
# See why a task missed cacheturbo build --summarize# Outputs .turbo/runs/*.json with full hash inputs# See what turbo would run without running itturbo build --dry-runturbo 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 runturbo build --graph# OpenTelemetry (2.9+, experimental)# turbo.json: { "futureFlags": { "experimentalObservability": true } }# Sends metrics to your OTLP backend
# Run a task across all packagesturbo buildturbo devturbo lint# Run only for specific packagesturbo build --filter=webturbo build --filter=@repo/uiturbo build --filter=./apps/web# Run for a package and all its dependenciesturbo build --filter=web...# Run for packages that changed vs mainturbo build --affected# Run for packages in a directoryturbo build --filter="./apps/*"
turbo dev # automatically uses TUI# Force specific UIturbo build --ui=tui # interactiveturbo 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.
Query your monorepo structure with GraphQL or shorthands:
# Open interactive GraphiQL playgroundturbo 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 packagesturbo query ls# Details for a specific packageturbo query ls web# List only affected packagesturbo query ls --affected
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
turbo prune creates a sparse workspace with only what one app needs:
# Create pruned workspace for the web appturbo 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 workspaceFROM base AS installerWORKDIR /appCOPY out/json/ .RUN pnpm install --frozen-lockfile# Build from full sourceFROM base AS builderWORKDIR /appCOPY --from=installer /app/node_modules ./node_modulesCOPY out/full/ .# Remote cache credentialsARG TURBO_TOKENARG TURBO_TEAMENV TURBO_TOKEN=$TURBO_TOKENENV TURBO_TEAM=$TURBO_TEAMRUN pnpm turbo build --filter=web# Production imageFROM base AS runnerWORKDIR /appCOPY --from=builder /app/apps/web/.next/standalone ./EXPOSE 3000CMD ["node", "server.js"]
# Run tasksturbo <task> # run task in all packagesturbo <task> --filter=<pkg> # run in specific packageturbo <task> --affected # run in changed packages onlyturbo <task> --force # force re-run, ignore cacheturbo <task> --dry-run # see what would runturbo <task> --dry-run=json # machine-readable dry runturbo <task> --graph # output task graphturbo <task> --summarize # output run summary with hash detailsturbo <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-onlyturbo build --cache=remote:rw # remote onlyturbo build --cache=local:r,remote:r # read-only (no cache writes)# Query (stable in 2.9)turbo query # open GraphiQL playgroundturbo query affected # show affected tasksturbo query affected --packages # show affected packagesturbo query affected --tasks build # affected build tasksturbo query ls # list all packagesturbo query ls web # details for web packageturbo query ls --affected # list affected packages# Remote cacheturbo login # authenticate with Vercelturbo link # link to Vercel Remote Cacheturbo logout# Devtoolsturbo devtools # open visual graph explorer# Upgradebunx @turbo/codemod migrate # automated upgrade with codemod
# 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
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.