Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

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.

Resolving a package's effective task configuration Turborepo starts from the root task definition, applies the package's turbo.json overrides key by key, and uses the result for that package's task. root turbo.json build: dependsOn ^build, outputs dist/** apps/web/turbo.j son extends //; build.outputs .next/** merge per key dependsOn inherited, outputs replaced effective task @acme/web#build
Each key is inherited or replaced as a whole; arrays such as outputs are replaced, not merged, unless you include the root value.
// 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.

Root package#task keys versus package configurations Compares defining exceptions in the root file with package#task keys and defining them in package-level turbo.json files. root package#task package turbo.json Where it lives central file inside the package Ownership / CODEOWNERS root owners package owners Inherits root defaults restate everything per-key inheritance Adds a task only one package has yes yes Discoverability one file to read one file per package
Package configurations keep exceptions next to the code they describe and let package owners change them.

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

  • extends must be ["//"]. Package configurations extend the root only; they cannot extend each other.
  • The root package.json cannot have a package configuration in the same way; root tasks are configured in the root file with the //#task syntax.
  • Only tasks, not global settings. globalEnv, globalDependencies and ui belong 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.

Root turbo.json size before and after moving overrides Line counts of the root turbo.json with package#task entries and after moving them into package configurations. root with package#task keys 180 lines root defaults only 30 lines largest package turbo.json 18 lines
Moving exceptions into packages leaves the root with only shared defaults.

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 dependsOn in 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