What Nx and Turborepo solve in a monorepo

A monorepo with thirty packages does not have one build. It has thirty builds, thirty test suites, thirty lint runs, and an ordering problem on top of them: a service can't be tested until the shared library it imports has been compiled. If you run all of that sequentially, every pull request pays for every package. If you script the ordering by hand, the script drifts as dependencies change, and nobody trusts it.

Nx and Turborepo both address this the same basic way:

  • They find the dependency graph from your workspace, so tasks run in the right order without a hand-written script.
  • They run independent tasks in parallel across the available CPU cores, instead of one package at a time.
  • They skip work that has already been done. Before a task runs, the tool hashes its inputs, such as source files, configuration, relevant environment variables and the outputs of upstream tasks. If that hash matches an earlier run, the tool replays the cached logs and artifacts instead of running the task again. A shared remote cache means one CI job can reuse work done by another job or by a developer's laptop.

Platform teams adopt this tooling because CI time and flaky pipelines grow with the size of the repo, while headcount does not.

On a typical repo, both tools will make CI noticeably faster than having no orchestration at all. So the real decision is less about speed and more about how much structure you want the tool to own. Turborepo stays close to your existing package.json scripts plus a single turbo.json. Nx can also take over project modeling, code generation and architectural rules. The rest of this article looks at that trade-off.

How each tool models your repo: task runner vs build system

Turborepo treats your workspace as it already is. Packages come from your package manager's workspace definition (npm, yarn or pnpm). Dependencies come from each package.json. The commands themselves are the scripts you already wrote. The only thing it adds is a turbo.json that describes relationships between tasks. A minimal pipeline declares a build task under tasks with "dependsOn": ["^build"], meaning "build my dependencies first," and "outputs": ["dist/**"], which tells Turborepo what to cache. Turborepo never needs to know what a script does internally, so you can adopt it one package at a time.

Nx builds an explicit project graph first and then derives a task graph from it. Projects can be defined in package.json, in a project.json with named targets, or inferred by plugins that read tool configuration and source imports. A minimal target in project.json looks much like the Turborepo version: a build target with a command or executor, "dependsOn": ["^build"], and outputs. The difference is where that model lives. In Nx it sits in a graph the tool owns, which is what makes affected detection, boundary rules and generators possible later. The core CLI works without any plugins, and plugins are optional.

scripts / targets → [project graph] → task graph → cache lookup → execute or restore

Both tools run the right-hand side: they hash inputs, check the cache, then either run the task or restore its outputs. Turborepo skips the bracketed step and works directly from workspace metadata. Nx builds that step explicitly, and the later sections of this article depend on it.

# Turborepo: one turbo.json at the repo root. The commands stay in each
# package's existing package.json "scripts"; turbo.json only relates tasks.
cat > turbo.json <<'EOF'
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}
EOF
# "^build" = run build in workspace dependencies first.
# "outputs" = files Turborepo stores in and restores from its cache.

# Nx: a project.json per project (this one is in a separate example repo).
# The target becomes part of the project graph that Nx owns.
mkdir -p packages/web
cat > packages/web/project.json <<'EOF'
{
  "name": "web",
  "targets": {
    "build": {
      "command": "tsc -p packages/web/tsconfig.json",
      "dependsOn": ["^build"],
      "outputs": ["{projectRoot}/dist"],
      "cache": true
    }
  }
}
EOF
# "command" runs a shell command without any plugin.
# Nx builds the project graph from this file plus package.json files.
# Plugins can infer targets like this one from tool configuration.

A minimal Turborepo pipeline and a minimal Nx project target, shown as two separate example setups, both declaring a cached build that waits for its dependencies' builds.

How build caching works and where it goes wrong

Both tools compute a hash from a task's inputs, look it up, and either run the task or replay its stored outputs and logs. The hash only covers what you tell the tool to consider. The quality of the cache depends on how carefully you declare inputs and outputs.

Inputs and outputs

Inputs are the source files, the hashes of dependency packages, environment variables, and configuration files.

  • Turborepo: you set inputs, outputs, and env per task in turbo.json. Repo-wide factors go in globalDependencies and globalEnv.
  • Nx: you define namedInputs such as production in nx.json, reference ^production to include dependencies' inputs, add { "env": "API_URL" } entries to inputs, and set outputs such as {projectRoot}/dist on targets.

Two failure modes follow from this:

  • Inputs declared too narrowly. A file or variable that affects the build is left out of the hash, so you get a hit and restore a stale artifact. A missing build-time env var can ship a production bundle that points at staging.
  • Inputs declared too broadly. Including READMEs or test files in a build hash produces misses that nobody can explain.

Outputs that are left undeclared are not restored on a hit, so downstream tasks fail or quietly rebuild.

Local vs. remote caching

A local cache helps only the machine that filled it. A remote cache shares artifacts between developers and CI, which is where most of the savings come from. It is also where the trust problems are:

  • Write access. Anyone who can write to the cache can plant artifacts that others will run. A common policy is to let CI write and give laptops read-only access.
  • Leaked environment. Values that differ between machines but are not hashed can make artifacts that look identical behave differently.

Expect broad invalidation when you change the lockfile, root tsconfig, turbo.json, or nx.json. These files feed many hashes at once.

# Same build-cache contract in both tools: what to hash, what to restore.

# Turborepo (turbo.json, v2 'tasks' key)
cat > turbo.json <<'EOF'
{
  "globalDependencies": ["tsconfig.base.json"],
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", "!README.md", "!**/*.test.ts"],
      "outputs": ["dist/**"],
      "env": ["API_URL"]
    }
  }
}
EOF

# Nx (nx.json)
cat > nx.json <<'EOF'
{
  "namedInputs": {
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/README.md",
      "!{projectRoot}/**/*.spec.ts",
      { "env": "API_URL" }
    ]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "outputs": ["{projectRoot}/dist"]
    }
  }
}
EOF

Equivalent build-task cache declarations in turbo.json and nx.json: both exclude docs and tests from the hash, include a shared root config file and the API_URL build-time variable, and declare dist as the output to restore on a cache hit.

Running only what changed in CI

Both tools answer the same question: which projects did this diff touch, and which projects depend on them? In Nx you run nx affected -t test --base=origin/main --head=HEAD. In Turborepo you use a filter such as turbo run test --filter=...[origin/main]. The bracketed ref selects packages changed since that commit, and the leading ... adds every package that depends on them. Without the ..., you test the changed library but skip the services that consume it.

The base ref is where CI setups most often go wrong. Many CI checkouts default to a shallow clone, so the base commit may not exist locally. When that happens, the diff either fails or quietly treats everything as changed. Fetch enough history to include the merge base. For pull requests, compare against the target branch. On the main branch, compare against the last commit whose pipeline passed, not HEAD~1. Otherwise a failed run's changes disappear from the next comparison.

Change detection is only as good as the dependency graph. Both tools infer edges from package.json dependencies, and Nx also infers them from source imports. Some relationships never appear in either place:

  • a shared ESLint config read by path
  • a generated client built from an OpenAPI spec
  • a Go service that the JS tests call over HTTP

Declare these edges explicitly. In Nx, use implicitDependencies or task inputs. In Turborepo, use inputs or globalDependencies in turbo.json. If you don't, a real change produces an empty affected set and a green build that tested nothing.

#!/usr/bin/env bash
# CI step: pick a correct git base, then run tests only for affected projects.
set -euo pipefail

# Shallow clones often lack the base commit. Fetch full history so the diff is real.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
  git fetch --no-tags --unshallow origin
fi
git fetch --no-tags origin main

if [ "${CI_BRANCH:-}" = "main" ]; then
  # On main, compare against the last commit whose pipeline passed, not HEAD~1.
  # Otherwise a failed run's changes drop out of the next comparison.
  # LAST_GREEN_SHA is a placeholder that your CI system or a stored marker supplies.
  BASE="${LAST_GREEN_SHA:?set LAST_GREEN_SHA to the last passing commit on main}"
else
  # On pull requests, compare against the merge base with the target branch.
  BASE="$(git merge-base origin/main HEAD)"
fi

# Stop here if the base commit is missing locally, rather than diffing against nothing.
git cat-file -e "${BASE}^{commit}"
echo "Affected range: ${BASE}..HEAD"

# Nx: projects changed in the range, plus everything that depends on them.
npx nx affected -t test --base="$BASE" --head=HEAD

# Turborepo equivalent. The leading ... adds dependents of the changed packages.
# npx turbo run test --filter="...[$BASE]"

A CI script that fetches enough git history and sets the base to the merge base on pull requests or the last green commit on main, then runs Nx affected or the equivalent Turborepo filter against that base.

Beyond caching: boundaries, CI orchestration, and language reach

A green build there is only as trustworthy as the dependency graph behind it. That graph also shapes what each tool offers past caching.

Boundaries. Nx lets you tag projects (for example scope:payments or type:data-access) and enforce rules between tags through its module boundary lint rule (Nx docs). This turns architecture decisions into failing checks. A UI library cannot quietly start importing a database client. Turborepo historically left this to the team, through ESLint import rules, package exports fields, or code review. Recent releases have begun adding boundary checks, so confirm their current status before you rely on them.

CI orchestration. Nx documents distributed task execution, splitting long e2e suites across machines, and automatic re-runs of flaky tasks. Turborepo parallelizes tasks across cores on one machine and shares results through Remote Cache (Turborepo docs). Fanning work out across runners is left to your CI configuration.

Language reach. Nx plugins can infer projects and tasks for non-JS toolchains. Turborepo follows npm workspace conventions, so a Go or Python service has to be wrapped in a package.json script.

Capability Nx Turborepo
Dependency rules between projects Tag-based lint rule Team-managed; check current docs
Multi-machine task distribution Built-in, hosted-service backed Your CI's job matrix
Flaky-task handling Automatic re-runs Your CI or test runner
Non-JS projects Plugins package.json wrappers
Configuration surface Larger One turbo.json

Some of these features, especially distribution and managed remote caching, depend on hosted services. Before you commit, check three things: which features are open source and which are paid, whether you can self-host the cache, and which artifacts or metadata leave your infrastructure.

Impact on cost and engineering time

Illustrative estimate (assumed numbers, not a benchmark)

Assume a repo with 40 packages, 3 tasks each (build, test, lint), so 120 tasks. Assume an average of 2 minutes per task, 25% of packages affected per change, and a 40% remote cache-hit rate among affected tasks. These hits come from work already done on another branch or in an earlier run.

CI minutes = tasks × affected share × (1 − cache-hit rate) × avg task minutes

Scenario Calculation CI minutes
Run everything, no cache 120 × 1.0 × 1.0 × 2 240
Affected only, cold cache 120 × 0.25 × 1.0 × 2 60
Affected only, 40% hits 120 × 0.25 × 0.6 × 2 36

These figures are total compute, not wall-clock time. Parallelism changes how long developers wait but not what you pay for. Neither tool makes a slow individual task faster. A 10-minute integration suite still costs 10 minutes whenever it misses the cache. Cold runs also cost the full amount: new CI runners, cache evictions, and changes to the lockfile or root config that invalidate most hashes. Ignore vendor claims about saved compute. Instead, record your real task count, durations, affected share, and hit rate for a few weeks, then put those numbers into the formula.

Adoption cost differs as well. Nx has a larger configuration surface: project inference, plugins, generators, executors, and boundary rules. That gives you more leverage, but it adds to the learning curve. It also couples your repo to Nx conventions and to plugin versions that must be upgraded alongside Nx. Turborepo reuses your existing package.json scripts plus a single turbo.json, so there is less to learn and less to unwind if you leave. The trade-off is that you assemble boundaries and code generation yourself.

Which to choose, when to avoid both, and alternatives

Choose Turborepo when

Your repo is mostly JavaScript or TypeScript packages with working package.json scripts, and your main problem is slow, repeated work. Turborepo adds scheduling and caching on top of what you already have, so adoption is mostly a matter of declaring task dependencies and outputs. Reach for it when you want speed now and do not want to restructure the repo to get it.

Choose Nx when

You need the tool to understand the project graph and act on it. That includes enforcing which projects may depend on which, distributing tasks across CI machines, and handling flaky tasks. These features depend on Nx's model of your workspace, so you accept more Nx-specific configuration and conventions in return. That trade is worth it when architectural drift or CI wall-clock time costs you more than the lock-in does.

When to avoid both

In a repo with a handful of packages and fast builds, the workspace scripts in your package manager may be enough. A caching layer adds configuration you have to keep correct, and stale-cache bugs are expensive to debug. In heavily polyglot backends, where Go, Java, or Rust services carry most of the build cost, a language-agnostic build system with hermetic, per-target dependency modeling usually fits better than JS-centric tooling.

Evaluating

Pilot each tool on one slice of the repo, such as a service and its shared libraries, for two to three weeks. Track local and CI cache-hit rates, median CI duration, and how many cache misses you could not explain. Unexplained misses usually mean undeclared inputs, and the tool that makes those misses easier to diagnose tends to be the better long-term fit.