Monorepo Management (Nx/Turborepo) Cheat Sheet
Covers workspace structure, task pipelines, remote caching, affected-project detection, and dependency graphs for Nx and Turborepo.
Turborepo `turbo.json` Pipeline
Define task dependencies and cache behavior once; Turborepo topologically orders and caches runs.
{ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] }, "test": { "dependsOn": ["build"], "outputs": [] }, "lint": { "outputs": [] }, "dev": { "cache": false, "persistent": true } }}
Turborepo CLI Commands
Run tasks across the whole workspace or scoped to changed packages.
# run build in every package that has a build script, respecting dependsOn orderturbo run build# only packages affected since the given git refturbo run test --filter=...[origin/main]# run for one package and its dependentsturbo run build --filter=@acme/ui...# remote caching (Vercel by default, or self-hosted)turbo loginturbo linkturbo run build --remote-only
Nx `nx.json` & Project Graph
Nx infers a project graph from imports and configures cacheable target defaults centrally.
{ "targetDefaults": { "build": { "dependsOn": ["^build"], "cache": true, "inputs": ["production", "^production"] }, "test": { "cache": true, "inputs": ["default", "^production"] } }, "namedInputs": { "default": ["{projectRoot}/**/*"], "production": ["!{projectRoot}/**/*.spec.ts"] }}
Nx CLI Commands
Nx's `affected` commands use the project graph to run only what changed.
# visualize the dependency graph in the browsernx graph# run a target for one projectnx build my-app# run a target for every project affected by uncommitted/branch changesnx affected -t build test lint --base=main# run with Nx Cloud distributed task execution + remote cachingnx affected -t build --parallel=5
Nx vs Turborepo Quick Comparison
Both solve caching/orchestration; they differ in scope and opinionation.
- Turborepo- lightweight task runner/cache, config in turbo.json, framework-agnostic, minimal opinions
- Nx- full toolkit: generators, plugins, project graph visualization, code-owned migrations
- Remote caching- both support it (Vercel Remote Cache / Nx Cloud or self-hosted alternatives)
- Affected detection- turbo uses --filter=...[ref]; Nx uses `nx affected` with --base/--head
- Task pipeline config- turbo.json `tasks` vs nx.json `targetDefaults`, both express dependsOn graphs
- Best fit- Turborepo for simpler JS/TS monorepos; Nx for large multi-framework orgs wanting generators/enforced boundaries
Environment Variables in the Cache Key
Declare which env vars affect a task's output so Turborepo invalidates the cache correctly instead of silently serving a stale artifact.
{ "tasks": { "build": { "dependsOn": ["^build"], "env": ["NEXT_PUBLIC_API_URL", "NODE_ENV"], "passThroughEnv": ["AWS_REGION"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"] } }, "globalEnv": ["CI"], "globalDependencies": ["tsconfig.base.json"]}
Enforcing Module Boundaries with Tags
Nx's ESLint rule blocks illegal cross-package imports (e.g. a feature importing another feature's internals) using tag-based rules, not just folder convention.
// project.json for libs/billing/feature-checkout{ "tags": ["scope:billing", "type:feature"]}// .eslintrc.json (root){ "rules": { "@nx/enforce-module-boundaries": ["error", { "depConstraints": [ { "sourceTag": "type:feature", "onlyDependOnLibsWithTags": ["type:ui", "type:util"] }, { "sourceTag": "scope:billing", "onlyDependOnLibsWithTags": ["scope:billing", "scope:shared"] } ] }] }}
Custom Nx Executor
Wrap an arbitrary script as a first-class, cacheable Nx target when a built-in executor doesn't cover it.
// tools/executors/db-migrate/executor.tsimport { ExecutorContext } from '@nx/devkit';import { execSync } from 'child_process';interface Options { schemaPath: string; }export default async function runExecutor(options: Options, context: ExecutorContext) { const project = context.projectName; execSync(`prisma migrate deploy --schema=${options.schemaPath}`, { stdio: 'inherit' }); return { success: true };}// project.json// "migrate": { "executor": "./tools/executors/db-migrate:executor",// "options": { "schemaPath": "apps/api/prisma/schema.prisma" } }
Pruned Subgraph for Docker Builds
Turborepo `prune` generates a minimal package.json/lockfile subset for one target so Docker layers only rebuild when that app's real dependencies change.
# generate a pruned workspace containing only @acme/api and its depsturbo prune @acme/api --docker# out/ now has: out/json (partial package.jsons for lockfile install layer)# out/full (actual source, copied in a later layer)# Dockerfile pattern:# COPY out/json/ .# RUN npm ci# COPY out/full/ .# RUN turbo run build --filter=@acme/api
Monorepo Gotchas at Scale
Issues that only surface once a monorepo has many teams and packages.
- Phantom dependencies- a package imports something hoisted into node_modules by a sibling instead of declaring it; breaks on strict installs (pnpm) or when the sibling is removed
- Circular project references- Nx's graph and Turborepo's dependsOn both fail or misorder tasks when two packages depend on each other
- Cache poisoning from non-deterministic output- build artifacts embedding timestamps/hashes make outputs differ between identical inputs, defeating cache hits
- Overly broad `outputs`/`inputs` globs- including log files or coverage reports in cache outputs invalidates hits that should have been reused
- CI runner cache isolation- ephemeral CI runners without a shared/remote cache pay the full cold-cache cost on every run, negating local dev speedups
- Version skew across packages- independently versioned internal packages drift; fixed/locked versioning (Nx release, Changesets) avoids "which version of @acme/ui is this on" bugs
Always scope CI to affected/changed projects (`turbo run test --filter=...[origin/main]` or `nx affected -t test --base=origin/main`) instead of running every task on every push — this is the single biggest lever for keeping monorepo CI times flat as the repo grows.