Turborepo’s caching only pays off if the commands Claude Code runs are the ones the cache actually understands. Tell it to run turbo run build with no filter, and it rebuilds the entire graph every time — cache hits or not, that’s still slower than turbo run build --filter=web. Have it write a turbo.json from a tutorial it remembers, and you get a pipeline key that Turborepo 2.x silently ignores because the schema moved to tasks back in Turborepo 2.0. None of this is exotic. It’s the gap between “Claude Code understands monorepos in general” and “Claude Code understands this specific build orchestrator’s config format, cache semantics, and CLI surface” — and that gap is what a Turborepo-specific CLAUDE.md closes.
This guide assumes you already have CLAUDE.md working in your monorepo — nested files per package, nothing bloating the root. If you haven’t set that up yet, our monorepo setup guide covers the hierarchy pattern first. This guide is narrower: it’s about the parts of a CLAUDE.md that only make sense once Turborepo specifically is the build tool — turbo.json syntax, remote cache behavior, the --filter query language, and the experimental boundaries command for enforcing package dependency rules.
Why “Turborepo” and “Monorepo” Need Separate Rules
A generic monorepo CLAUDE.md — the kind that says “this is a pnpm workspace, packages live under packages/” — works the same whether the build tool is Turborepo, Nx, or plain npm scripts. That’s useful, but it’s also exactly why it doesn’t help with Turborepo’s specific failure modes:
The config schema changes between major versions, and agents don’t track that automatically. Turborepo 2.0 replaced the pipeline key with tasks in turbo.json. An agent that’s seen more pre-2.0 tutorials than post-2.0 ones in training will write the old key. Turborepo doesn’t error on an unrecognized top-level key — it just doesn’t run the tasks you defined, which shows up as “nothing happens” rather than a clear failure.
The cache is only as good as the commands hitting it. turbo run build with no --filter builds and (on a cache miss) caches every package in the graph. That’s correct for CI running the full suite, but it’s the wrong default for local iteration on one package. Without an explicit rule, Claude Code reaches for the unscoped command because it’s shorter and it’s what most package.json scripts show as the top-level build script.
Boundaries enforcement is new enough that most training data predates it. turbo boundaries — tag-based rules for which packages can depend on which — shipped experimentally in Turborepo 2.4.2. It’s recent enough that an agent won’t reach for it unless the config already demonstrates the pattern.
The turbo.json Task Schema (Turborepo 2.x)
Put this in your CLAUDE.md verbatim if your repo is on Turborepo 2.0 or later — it prevents the single most common mistake, which is writing a pipeline block that does nothing.
## turbo.json Schema (Turborepo 2.x)
The top-level key is `tasks`, not `pipeline` — that was renamed in Turborepo
2.0. If you see `pipeline` in an example, mentally substitute `tasks`.
\`\`\`jsonc
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**/*.ts", "test/**/*.ts", "$TURBO_DEFAULT$"],
"outputs": []
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
\`\`\`
Key rules:
- `dependsOn: ["^build"]` (with the caret) means "wait for this package's
*dependencies'* build tasks" — not its own. Use `dependsOn: ["build"]`
(no caret) for same-package task ordering, e.g. `test` depending on `build`
in the same package.
- `outputs: []` (or omitting `outputs`) caches logs only, not files. Set this
explicitly for tasks like `lint` and `test` that don't produce build
artifacts worth caching.
- `persistent: true` marks a task as long-running (dev servers, watch mode).
Turborepo won't wait for a persistent task to "finish" before considering
dependent tasks unblocked, and it refuses to let two persistent tasks
depend on each other.
- `cache: false` on `dev` — never cache a dev server's output.
Why this matters enough to spell out explicitly: the pipeline → tasks rename is exactly the kind of change that stale training data gets wrong silently. Turborepo doesn’t throw an error for an unrecognized pipeline key — it just runs no tasks, and the failure mode looks like “my turbo.json isn’t doing anything” rather than a clear syntax error.
Composable Configuration (Turborepo 2.7+)
This is recent enough — December 2025 — that most Turborepo tutorials still in circulation don’t cover it, and it’s genuinely useful for monorepos where package-level task overrides used to mean duplicating the whole task definition.
## Package-Level turbo.json (Composable Configuration)
A package can extend the root config instead of redefining tasks from
scratch:
\`\`\`jsonc
// packages/ui/turbo.json
{
"extends": ["//"],
"tasks": {
"build": {
"outputs": ["dist/**", "storybook-static/**"]
},
"lint": {
"extends": false
}
}
}
\`\`\`
- `"extends": ["//"]` at the top level means "inherit the root turbo.json's
task definitions as the base."
- Redefining `build` here *merges* with the root definition — this package
adds `storybook-static/**` to the cached outputs without having to restate
`dependsOn`.
- `"extends": false` inside a specific task (not at the top level) removes
that task from the inheritance chain entirely — use this when a package
genuinely shouldn't run a task the root defines, e.g. a docs-only package
skipping `lint` because it has no source files ESLint applies to.
Don't redefine an entire task block to change one field. If you're rewriting
`dependsOn`, `outputs`, and `inputs` all to change one caching behavior,
something is wrong with the root config — fix it there instead of
overriding at every package.
The failure mode this prevents: an agent asked to “add a package-specific build output path” will often paste the full task definition into the package’s turbo.json, duplicating dependsOn and inputs it didn’t need to touch. Six months later, someone updates the root dependsOn and three package-level overrides silently keep using the stale value because they redefined instead of extended.
Remote Caching: Setup and the Vercel vs Self-Hosted Decision
## Remote Caching
This repo uses [Vercel Remote Cache / self-hosted cache server — specify
which].
**Vercel Remote Cache (default, no infra required):**
\`\`\`bash
npx turbo login
npx turbo link
\`\`\`
This works regardless of whether the app itself deploys to Vercel — Remote
Cache is a separate product from Vercel hosting.
**CI setup (GitHub Actions):**
\`\`\`yaml
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
\`\`\`
Never hardcode `TURBO_TOKEN` in a workflow file. It goes in repository
secrets, and CI logs should never print its value — Turborepo doesn't echo
it by default, but a custom script that dumps `env` for debugging will.
**If self-hosting instead:** set `TURBO_API`, `TURBO_TOKEN`, and
`TURBO_TEAM` to point at the self-hosted cache server's URL instead of
Vercel's. The task-level cache behavior (hashing, hit/miss logic) is
identical either way — only the storage backend changes.
The decision between Vercel Remote Cache and self-hosting isn’t really about speed — both are fast enough that the difference is marginal for most teams. It’s about where build artifacts are allowed to live. A team under a compliance requirement that prohibits sending internal build output to third-party infrastructure needs self-hosting regardless of Vercel’s performance; a team without that constraint gets Remote Cache working in two commands and should probably stop there. Document which one your repo actually uses — an agent given no signal will assume Vercel Remote Cache, since turbo login is the path of least resistance, and that’s the wrong default for a self-hosted setup.
The —filter Flag: Beyond --filter=web
Most CLAUDE.md files that mention Turborepo at all stop at --filter=<package-name>. The flag supports git-aware and dependency-aware queries that matter a lot more for CI performance than the basic form.
## --filter Patterns
- `--filter=web` — only the `web` package.
- `--filter=web...` — `web` and everything `web` depends on (needed to
actually build `web`, since Turborepo won't implicitly build dependencies
outside the filtered set unless you use this form or `dependsOn: ["^build"]`
handles it).
- `--filter=...web` — `web` and everything that depends on `web` (useful for
"what could this change break").
- `--filter=[HEAD^1]` — only packages changed since the last commit. This is
the one that actually matters in CI: combined with `dependsOn: ["^build"]`,
it turns "build everything" into "build what changed and what depends on
it," which is where most of Turborepo's CI time savings come from.
- `--filter=[main...HEAD]` — packages changed since diverging from `main`,
for PR-scoped CI runs instead of single-commit diffs.
For local development, default to a scoped filter — `turbo run dev
--filter=web...` — not the unscoped `turbo run dev`. The unscoped form starts
every package's dev process, which is rarely what you want when working on
one app.
Say explicitly in CLAUDE.md which filter form CI should use — [HEAD^1] for single-commit builds versus [main...HEAD] for PR-scoped builds are genuinely different semantics, and picking the wrong one either rebuilds too much (defeating the cache) or misses changes from earlier commits in the same PR.
Enforcing Workspace Boundaries with turbo boundaries
This is the newest piece here, and the one no generic monorepo guide covers, because it’s Turborepo-specific tooling, not a filesystem convention.
## Workspace Boundaries (Experimental)
This repo uses \`turbo boundaries\` to enforce two things: no importing files
outside a package's own directory, and no importing a package that isn't
declared as a dependency in that package's own package.json (catches
"phantom dependencies" that only resolve because pnpm's hoisting happens to
expose them).
Tag packages in each package's turbo.json:
\`\`\`jsonc
// packages/ui-internal/turbo.json
{
"tags": ["internal"]
}
\`\`\`
Declare the rule at the root:
\`\`\`jsonc
// turbo.json
{
"boundaries": {
"tags": {
"public": {
"dependencies": { "deny": ["internal"] }
}
}
}
}
\`\`\`
This means any package tagged \`public\` (e.g. a published npm package) that
tries to import from a package tagged \`internal\` fails the boundaries
check — even if the import would otherwise resolve fine at the filesystem
and package.json level.
Run it: \`turbo boundaries\`
**Caveat as of 2026:** boundaries is still experimental and has known gaps
with TypeScript path alias resolution — a local import via a \`tsconfig.json\`
\`paths\` alias can be incorrectly flagged as an undeclared dependency. Treat
a boundaries failure as "investigate this," not "this is definitely a real
violation," until you've confirmed your path aliases resolve the way
boundaries expects.
The scenario this actually catches in practice: a package meant to be an internal implementation detail (say, packages/db-internal) gets imported directly from a public-facing package because pnpm’s workspace resolution makes that import work without complaint — package.json doesn’t declare it as a dependency, but pnpm hoists it into scope anyway. boundaries catches that specific case, where boundaries-less linting wouldn’t, because ESLint import rules typically check declared dependencies, not tag-based architectural rules.
What the PS Gallery Shows in Real Turborepo Configs
Two entries in our gallery are worth pulling from directly if you’re starting a config from scratch: a general Turborepo Monorepo CLAUDE.md and a Turborepo AGENTS.md pulled from a repository with 26,000+ stars — enough real-world usage that its task and cache conventions have been through more edge cases than a from-scratch config will hit in its first year. Reading both side by side is a faster way to see which parts of a Turborepo config are boilerplate (commands, workspace layout) versus which parts genuinely vary by project (cache output globs, which tasks are safe to run unscoped).
A Note on turbo gen and Scaffolding
If your repo uses turbo gen for package scaffolding (via @turbo/gen and turbo.json’s generator config), tell Claude Code to use it instead of hand-copying an existing package directory:
## New Package Scaffolding
Use \`turbo gen workspace\` to create a new package — do not manually copy an
existing package directory and rename fields. The generator handles
package.json naming conventions, tsconfig references, and turbo.json tag
assignment consistently; a hand-copied package reliably misses one of those
and causes a boundaries or build failure days later that's hard to trace
back to "the package was scaffolded wrong."
Skip this section entirely if the repo doesn’t have generators configured — there’s nothing to enforce.
Complete CLAUDE.md Template
# CLAUDE.md — Turborepo Monorepo
## Package manager
[pnpm / npm / yarn] workspaces + Turborepo [version].
## turbo.json schema
Top-level key is \`tasks\`, not \`pipeline\` (renamed in Turborepo 2.0).
\`dependsOn: ["^build"]\` (caret) = wait for dependencies' build tasks.
\`dependsOn: ["build"]\` (no caret) = same-package task ordering.
## Commands
- Build one package: \`turbo run build --filter=<package>...\`
- Build everything: \`turbo run build\` (CI only — don't use for local
iteration on one package)
- Dev (scoped): \`turbo run dev --filter=<package>...\`
- Test changed packages only: \`turbo run test --filter=[HEAD^1]\`
- PR-scoped CI: \`turbo run build test --filter=[main...HEAD]\`
## Remote cache
[Vercel Remote Cache / self-hosted — specify which and why]. TURBO_TOKEN
lives in CI secrets, never hardcoded in workflow files.
## Package-level turbo.json (Composable Configuration)
Package overrides use \`"extends": ["//"]\` to inherit the root config, then
override only the fields that differ. Don't redefine a whole task to change
one field — that silently diverges from future root config changes.
## Workspace boundaries
[If using \`turbo boundaries\`: describe the tag scheme and which
dependencies are denied. If not configured: state "not yet configured" so
Claude Code doesn't assume tags exist.]
## What to avoid
- \`pipeline\` key in turbo.json (renamed to \`tasks\` in 2.0).
- Unscoped \`turbo run build\`/\`turbo run dev\` for local iteration — use
\`--filter\`.
- Hand-copying a package directory to scaffold a new one if \`turbo gen\` is
configured.
- Redefining a full task block in a package-level turbo.json instead of
extending and overriding only what differs.
- Committing TURBO_TOKEN anywhere outside CI secrets.
CLAUDE.md vs AGENTS.md for Turborepo Projects
The task pipeline, remote cache setup, and --filter conventions above are tool-agnostic in the sense that any AI coding assistant working in the repo needs the same information — they belong in AGENTS.md. CLAUDE.md is the place for Claude Code-specific behavior on top of that: whether to run turbo boundaries automatically before considering a refactor complete, or how much detail to show when a cache miss triggers an unexpectedly large rebuild.
AGENTS.md → turbo.json schema, --filter conventions, remote cache setup, boundaries rules
CLAUDE.md → "run turbo boundaries before finishing any cross-package refactor", explanation depth
If your monorepo already has a general AGENTS.md covering the CLAUDE.md hierarchy pattern from our monorepo guide, add a ## Turborepo section to it rather than maintaining a separate file for build-tool specifics.
Putting It Together
The rules that prevent the most wasted CI minutes are the schema and filter ones: tasks not pipeline, and a scoped --filter instead of an unscoped turbo run build for anything that isn’t a full CI run. Everything past that — Composable Configuration, remote cache backend choice, boundaries — matters more as the repo grows, but those two are the ones that turn into daily friction when missing from day one.
Start from the template above, fill in your actual remote cache backend and package manager, and add the boundaries section only once you’ve actually configured tags — an agent given boundaries instructions for a repo that hasn’t set up tags yet will either invent tag names or skip the check entirely, neither of which is useful.
You can find real Turborepo CLAUDE.md and AGENTS.md examples — including one from a 26,000-star repository — in our rules collection.
FAQ
Does Claude Code know Turborepo’s task pipeline syntax without being told?
No — and getting it wrong is silent, not a clear error. Turborepo 2.0 renamed the pipeline key to tasks; an agent that writes the old key produces a turbo.json that runs no tasks rather than throwing a syntax error.
What is Turborepo’s boundaries command and is it safe to enable?
turbo boundaries (experimental since Turborepo 2.4.2) checks for undeclared cross-package imports and enforces tag-based dependency rules. It’s safe to run as a CI check, but as of 2026 it has known gaps with TypeScript path alias resolution, so treat failures as something to investigate rather than an automatic blocker until verified against your tsconfig.
How is Composable Configuration different from the old extends behavior?
Turborepo 2.7’s Composable Configuration lets a package-level turbo.json inherit the root config via "extends": ["//"] and then merge or exclude ("extends": false) individual tasks, instead of having to restate an entire task definition to change one field.
Should Claude Code use Vercel Remote Cache or a self-hosted cache server?
Vercel Remote Cache needs no infrastructure and works regardless of hosting provider; self-hosting mainly matters for compliance requirements around where build artifacts live. Performance is comparable either way — the deciding factor is usually policy, not speed.
Why does turbo run build sometimes rebuild a package with no code changes?
Turborepo hashes a task’s inputs — by default, every source-controlled file in the package plus resolved internal-dependency versions and listed env vars. An unrelated env var, a lockfile bump in a transitive dependency, or an overly broad inputs glob can all invalidate the cache without the package’s own source changing.