turbo.json task graph

why

task order in tribal knowledge and root convenience scripts gives the repository no enforceable lifecycle: stale artifacts pass as fresh, uncacheable steps get cached, and every consumer guesses whether to build a dependency first. modeling real input/output edges, outputs, and boundaries makes the pipeline a checked declaration, not a remembered sequence.

rule

Turbo is the repository lifecycle graph, not a command shortcut: which outputs exist, what runs first, what stays uncached, which package boundaries are illegal.

the root is sacred: its package.json coordinates Turbo only — no application dependencies, package tooling, or convenience scripts. standard full pipeline:

pnpm install && turbo run generate format build lint typecheck
  • pnpm install establishes the exact lockfile graph before Turbo runs package tasks.
  • generate, format, build, lint, typecheck are stable lifecycle names.
  • add a task only with a real package owner and a verified output or failure boundary.

model facts, not command order

a task edge must express a real input/output dependency: ^build means the consumer reads a dependency's built surface; a local generate dependency means the task reads generated files from its own package. never add an edge just because the command usually runs first — it must protect a real fact that would otherwise be stale or absent.

taskinputs it may rely onoutput/cache policy
generateauthored schemas, route definitions, generator configurationuncached when it writes derived artifacts; outputs are declared where supported.
formatauthored source and the package's formatter configurationno shared root command; it formats only the owning package.
buildlocal generated files and dependency build surfacesdepends on generate and ^build; cache only declared build output.
lintauthored source and the package's lint configurationpackage-local diagnostic boundary; cache only when inputs/config are declared.
typecheckauthored types, generated declarations, dependency public typesdepends on the producer phase that makes those inputs available.
devcurrent source and local generatorspersistent and uncached.
deployverified build output and environment configurationuncached and owned by the deployable package.

required tasks

  • generate: uncached, owns generated outputs.
  • format / lint: run through the owning package script, never a shared root script.
  • build: depends on local generation and ^build; declares real outputs.
  • typecheck: depends on build when generated declarations or built surfaces are inputs.
  • dev / deploy: uncached; dev is persistent.

do not

  • let a consumer build before the producer's generation task.
  • cache deployment or generated output without a reviewed reason.
  • encode package ownership in opaque root scripts.
  • add a root ESLint, formatter, or application-tool script for package work.
  • let a package omit its lifecycle script and rely on a root fallback.

decision process

a consumer failing on a missing generated type is not fixed by a second generator command in the consumer. trace the missing file to its owner, make that producer's generate task explicit, then make the consumer depend on the producer's public build surface. this keeps local development and remote cache aligned instead of relying on a developer's command history.

root and package split

locationallowed responsibilityforbidden responsibility
root package.jsonpackage manager declaration, workspace metadata, and Turbo orchestrationapplication/library dependencies, per-package ESLint or formatter scripts, and convenience fan-out aliases.
each workspace package.jsonits own generate, format, build, lint, typecheck, dev, and deploy scripts when applicableinvoking another workspace's private source or configuration.
package-local config/ folderthat package's ESLint/formatter configuration when it would clutter its package rootbecoming an undeclared repository-wide config owner.

every package chooses and versions its own lint and format configuration — at its root or in a local config/ folder — and its own scripts invoke it. never centralize ESLint or formatting into root scripts or a hidden shared fallback.

good and bad

bad

// root package.json - violates ownership
{
"scripts": {
"lint:all": "eslint 'apps/**/*' 'packages/**/*' --config root-eslint.js",
"build:frontend": "cd apps/frontend && npm run build",
"build:backend": "cd apps/backend && npm run build"
}
}
// apps/backend/package.json - missing dependency
{
"scripts": {
"build": "tsc" // no generate dependency
}
}

lint:all reaches into every workspace with one shared ESLint config; the consumer's build omits the generate dependency because the developer's shell history happened to run generation first — remote cache hits now build against absent generated files.

good

// root package.json - coordinates only
{
"scripts": {
"install": "pnpm install",
"full": "turbo run generate format build lint typecheck"
}
}
// apps/backend/package.json - owns its lifecycle
{
"scripts": {
"generate": "drizzle-kit generate",
"build": "turbo run build --filter=... --deps=$.^build",
"lint": "eslint 'src/**/*' --config eslint.config.js"
}
}
// turbo.json - declares real edges
{
"tasks": {
"build": {
"dependsOn": ["^build", "generate"],
"outputs": [".next/**", "dist/**"]
},
"generate": {
"cache": false,
"outputs": ["generated/**"]
}
}
}

each package owns its generate, lint, and build scripts with its own config; build declares generate and ^build as inputs, so a clean clone and the remote cache match local development.

verification

run the graph from a clean derived-output state. confirm: generation precedes every consumer that needs it; a second unchanged build restores from the intended cache; dev/deploy are never reported as cacheable.

search pages

go to any page