Using Package-Level turbo.json Overrides
A single root turbo.json works until packages stop being alike. A Next.js app writes to .next/, a library to dist/, a CLI to bin/; one package needs an extra environment variable, another has a code generation step before its build. Encoding every exception in the root file with package#task keys makes it long and couples every package's configuration to one file owned by nobody. Package configurations — a turbo.json inside a package that extends the root — let each package override just what differs. This guide explains how they merge, what can and cannot be overridden, and how to keep the root file as the single source of defaults.
When a package needs its own configuration
Typical reasons, each of which shows up as a wrong cache result or a missing file:
# Outputs differ: the root says dist/**, but this app writes .next/
@acme/web:build: cache hit, replaying logs
# ...then the deploy step fails: .next/ was never restored from cache
Error: Could not find a production build in the '.next' directory.
# One package needs a pre-build step the others do not
@acme/api-client:build: error TS2307: Cannot find module './generated/schema' or its corresponding type declarations.
# An extra env var only matters to one package, but declaring it globally invalidates every build
• Cached: 3 cached, 41 total
The root-file alternative, "@acme/web#build": { ... } entries, works but scatters package knowledge into a central file. Every package change then touches a file shared by every team, and the entry must restate the full task definition rather than only the parts that differ. The general configuration model is covered in Turborepo Pipeline Configuration.
How package configurations merge
A package configuration must extend the root with "extends": ["//"] (// means the workspace root). For each task it defines, keys it sets replace the root's values for that key; keys it does not set are inherited.
// turbo.json (root)
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"inputs": ["$TURBO_DEFAULT$", "!**/*.test.ts"]
},
"test": { "dependsOn": ["build"] }
}
}
// apps/web/turbo.json
{
"$schema": "https://turborepo.com/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"outputs": [".next/**", "!.next/cache/**"],
"env": ["NEXT_PUBLIC_*"]
}
}
}
The effective @acme/web#build depends on ^build and uses the root's inputs, but writes .next/** and hashes NEXT_PUBLIC_*.
Arrays replace rather than merge. To add to an inherited array instead of replacing it, Turborepo supports the $TURBO_EXTENDS$ token as the first element (in recent 2.x releases):
{
"extends": ["//"],
"tasks": {
"build": {
"env": ["$TURBO_EXTENDS$", "SENTRY_RELEASE"]
}
}
}
That keeps the root's env list and appends SENTRY_RELEASE, rather than silently dropping everything the root declared.
Adding package-only tasks
A package configuration can also define tasks the root does not have, and make existing tasks depend on them — useful for code generation:
// packages/api-client/turbo.json
{
"extends": ["//"],
"tasks": {
"generate": {
"inputs": ["openapi.yaml"],
"outputs": ["src/generated/**"]
},
"build": {
"dependsOn": ["^build", "generate"]
}
}
}
Because generate is cacheable with precise inputs, the generator only runs when openapi.yaml changes. Note that the package override of dependsOn replaces the root's list, so ^build is restated.
Rules and limitations
extendsmust be["//"]. Package configurations extend the root only; they cannot extend each other.- The root
package.jsoncannot have a package configuration in the same way; root tasks are configured in the root file with the//#tasksyntax. - Only tasks, not global settings.
globalEnv,globalDependenciesanduibelong to the root file; a package configuration cannot change them. - Some options cannot be overridden in certain combinations — for example, a persistent task cannot become a dependency just because a package configuration says so. Invalid combinations fail validation.
Common override recipes
A handful of overrides cover most real packages. Collecting them in one place makes it easier to spot when a package is doing something unusual.
Framework output folders. Next.js writes to .next/ (exclude .next/cache/**), Nuxt to .output/, SvelteKit to .svelte-kit/ and build/, Astro and Vite apps to dist/, Expo exports to dist/ or platform folders. Each app overrides outputs for build so cache hits restore the folder its deploy step expects.
Extra inputs. A package that generates code from a schema, reads a .graphql folder outside src/, or embeds files from public/ should list those inputs explicitly with $TURBO_DEFAULT$ first, so the defaults are kept:
{
"extends": ["//"],
"tasks": {
"build": { "inputs": ["$TURBO_DEFAULT$", "../../schemas/api/**"] }
}
}
Referencing files outside the package is allowed, but it is a sign that the shared files might deserve their own package, which would make the relationship visible in the dependency graph rather than hidden in an input glob.
Uncacheable steps. A package whose build embeds a timestamp, a git SHA or a random build ID cannot be cached safely unless those values are declared inputs. Either pass them as declared environment variables (and accept a cache miss when they change) or set "cache": false for that package's build and let dependents still cache.
Slower or heavier tasks. Packages whose tests need a database or browser can declare a different test task that depends on a db:setup task, while lighter packages keep the root definition.
Migrating from package#task keys
If your root file already contains package#task entries, move them one package at a time. For each package, create turbo.json with extends: ["//"], copy the keys from the root entry that differ from the root's generic task definition, and delete the root entry. Keys in a package#task entry fully define that task for the package, while package configurations inherit per key, so copying the whole entry would restate defaults unnecessarily — copy only the differences. Run the dry-run comparison after each package to confirm nothing changed, and commit per package so a mistake is easy to find.
Reviewing package configurations
Package-level files spread configuration across the repository, which makes review discipline more important. Three checks keep them honest. First, every package turbo.json should start with "extends": ["//"] and contain only tasks; a lint script over **/turbo.json can enforce both. Second, pull requests that change a package configuration should include the dry-run diff of resolved tasks for that package, which shows reviewers the effective change rather than the raw override. Third, periodically look for overrides that restate the root's value exactly — they are leftovers from earlier refactors and can be deleted, which keeps each package file limited to real differences.
Worked example: moving exceptions out of the root file
A root turbo.json has grown to 180 lines, 140 of which are package#task entries for twelve packages. Every change needs approval from the platform team that owns the root file. The team moves each package's entries into a turbo.json inside that package, using extends: ["//"] and restating only the keys that differ. The root shrinks to 30 lines of genuine defaults, CODEOWNERS routes package configuration changes to package owners, and a dry run (turbo run build --dry=json) before and after the move shows identical resolved configuration for every task — proving the refactor changed nothing.
Validation commands
# Show the resolved configuration for one package's task
pnpm turbo run build --filter=@acme/web --dry=json | jq '.tasks[] | select(.taskId=="@acme/web#build") | {outputs, inputs, env: .environmentVariables, dependencies}'
# Compare resolved config for all tasks before and after a refactor
pnpm turbo run build test --dry=json | jq -S '.tasks' > after.json && diff before.json after.json
Prevention and CI/CD guardrails
- Keep shared defaults in the root and only differences in packages.
- Use
$TURBO_EXTENDS$when adding to inherited arrays, so root declarations are not dropped. - Restate
dependsOnin full when overriding it. - Diff dry-run output when refactoring configuration, to prove behaviour is unchanged.
Frequently Asked Questions
Do package configurations work with remote caching? Yes. They change the resolved task definition, which feeds the hash like any root configuration.
Can a package disable a task defined in the root?
Define the task with "cache": false or remove the script from the package. A task only runs in packages that have a matching script.
Does the codemod for Turborepo 2 update package configurations?
Yes. @turbo/codemod migrate updates every turbo.json in the repository, as described in Migrating turbo.json from pipeline to tasks.
Where should a task that only one package has be defined?
In that package's turbo.json. Defining it in the root makes every other package look like it is missing a script, and makes the root file describe work that only one team cares about.
How do I see which packages have their own configuration?
find . -name turbo.json -not -path "./node_modules/*" -not -path "./turbo.json" lists them. Pair it with the dry-run output to see how each one changes its tasks.
Related
- Turborepo Pipeline Configuration covers the root configuration these files extend.
- Fixing Missing Environment Variables in Turborepo Strict Mode explains per-package environment declarations.
- Setting Up CODEOWNERS for Monorepo Packages routes package configuration changes to the right reviewers.
- Debugging Why a Turborepo Task Is Never Cached helps when outputs or inputs are misconfigured.