What does migrating a monorepo to Nx actually change, and who should do it?

Migrating to Nx replaces the task runner and adds a project graph. Your package manager workspaces, lockfile, and package.json scripts stay as they are. Nx can run existing scripts as targets, so build, test, and lint keep working while Nx decides what to run, in what order, and what it can restore from cache.

Teams usually migrate for three reasons:

  • Affected-based CI: Nx compares the changed files against the project graph and runs only the projects that a commit can actually impact.
  • Task caching: Nx hashes task inputs so that unchanged builds and tests are replayed from cache instead of being executed again.
  • Module boundary enforcement: Nx lets you tag projects and enforce rules about which projects may import which, for example preventing one service from importing another service's internals.

The migration pays off most for backend and platform teams with many services, shared libraries, and CI bills that are noticeable. It is usually not worth the effort for small repos with only a handful of packages, or for teams that are already satisfied with Turborepo's caching.

Capability Turborepo Lerna (legacy runner) Lerna (tasks via Nx) Nx
Task pipelines turbo.json dependencies Topological order only Nx task graph nx.json target defaults
Caching Local and remote None Nx cache Local and remote
Affected detection Git-range filters --since --since via Nx Graph-aware affected
Versioning/publishing Not built in lerna version/publish lerna version/publish Built-in release tooling
Plugins Generators only None Nx plugins optional Inference and generator plugins
Config surface Small Small Moderate Largest

Full Nx is only one of the realistic options. You can also stay on Turborepo, fall back to plain workspace scripts, or keep Lerna for publishing while it delegates task running to Nx.

How does Nx work?

Nx does four things when you run a command such as nx affected -t build. Knowing them helps you find problems during the migration, because every surprising result traces back to one of these steps.

  1. It builds a project graph. Nx treats each workspace package as a project. It reads the internal dependencies declared in each package.json. For JavaScript and TypeScript projects it also analyses source imports, so it can find dependencies that were never declared. The result is a graph of projects and the edges between them. nx graph shows it.
  2. It derives a task graph. A task is a target on a project, such as orders-api:build. The dependsOn settings in nx.json or in project configuration expand one requested task into an ordered set. For example, "^build" means "build every project this one depends on first." Nx runs independent tasks in parallel and dependent tasks in order.
  3. It hashes each task before running it. The hash is computed from the files matched by the task's inputs, the hashes of upstream tasks, any declared environment variables, and the external dependency versions that apply. If the hash matches a cache entry, Nx restores the declared outputs and replays the recorded terminal output. It does not execute the task. If the hash does not match, Nx runs the script and stores its outputs under that hash.
  4. It computes affected projects from git. nx affected diffs a base commit against a head commit. It maps each changed file to the project that owns it, then walks the project graph to add every project that depends on those projects. Changes to files that all projects treat as global inputs mark everything as affected.

A long-running daemon process keeps the project graph in memory between commands so it does not have to be recomputed each time. That makes local runs faster. It is also why nx reset and NX_DAEMON=false show up in the troubleshooting steps later in this article.

Nx itself never interprets your build. It runs the same package.json scripts you already have. All of its correctness depends on the graph being complete and on the inputs and outputs being declared accurately. The rest of this migration is about making those two things true.

What are the alternatives to migrating to Nx?

Before committing, compare the realistic options. Each one is a legitimate end state, not only a stepping stone.

  • Stay on Turborepo. Turborepo already provides task pipelines, local and remote caching, and git-range filtering. If your CI times are acceptable and you have no coupling problems between services, the migration mostly buys you configuration churn. Reasons to move anyway are Nx's graph-aware affected built from source imports, tag-based module boundaries, and target inference through plugins.
  • Use plain package-manager workspaces. Your package manager's workspace commands can run a script across all packages, or a filtered set, in dependency order. You get no caching and no affected detection. For a small repo with fast builds this is the lowest-maintenance option, and every engineer already knows the tools.
  • Keep Lerna, with task running delegated to Nx. Lerna keeps lerna version and lerna publish, and Nx handles execution and caching. This is the lowest-risk step for repos that publish packages, because release workflows stay untouched. The cost is two tools with overlapping configuration.
  • Keep the legacy Lerna runner. This is viable only if builds are already fast. You get topological ordering and --since filtering, but no caching.
  • Adopt full Nx. You get the most capability: affected detection, caching, module boundaries, plugins and release tooling. It also has the largest configuration surface and the tightest coupling to one tool's upgrade cycle.

The deciding question is usually not which tool has more features. It is whether your current CI time, cache correctness, or cross-service coupling is causing problems that someone on the team spends real time working around. If none of them is, staying put is a reasonable choice.

What should you inventory before running nx init?

Whichever path you choose, the inventory is the same, and it is cheap to collect while the current tool is still the source of truth. Once you run nx init, the old behaviour stops being something you can observe and becomes something you have to remember.

Record the following before touching anything:

  • Packages: every workspace package with its build, test and lint scripts, copied verbatim. Note which ones are only tsc or a no-op echo.
  • Dependencies: the internal edges from each package.json, including dev dependencies that matter at build time, such as a shared ESLint config.
  • Task configuration: your turbo.json pipeline (dependsOn, outputs, inputs, environment variables), or Lerna's --scope/--since usage and any nx.json it already relies on.
  • Cache and CI: where cache artifacts live, which CI jobs exist, and typical wall-clock durations from recent green runs.

Then capture a handful of change scenarios. For each one, record exactly which packages the current tool runs.

Scenario Files touched Packages run today Expected after Nx
Shared library change libs/db/src/client.ts db, orders-api, billing-api Same set
Single service services/orders-api/src/routes.ts orders-api Same set
Codegen schema schema/events.proto codegen, every consumer Same set

These rows become regression tests for the project graph. After migration you rerun each scenario and compare the results. Any difference is either a missing input or a dependency the old tool never saw. Without this baseline, a smaller affected set looks like an improvement until a stale service ships.

#!/usr/bin/env bash
set -euo pipefail
OUT=migration-baseline
mkdir -p "$OUT"

for pkg in $(git ls-files '*/package.json' | grep -v node_modules); do
  jq -c '{name, scripts: (.scripts // {} | {build, test, lint})}' "$pkg"
done > "$OUT/scripts.jsonl"

cp turbo.json "$OUT/turbo.json"

scenarios=(
  "shared-lib:libs/db/src/client.ts"
  "single-service:services/orders-api/src/routes.ts"
  "codegen-schema:schema/events.proto"
)

git switch -c baseline-scratch
for s in "${scenarios[@]}"; do
  name="${s%%:*}"; file="${s#*:}"
  echo "// baseline probe" >> "$file"   # trivial edit, comment syntax for ts/proto
  git commit -qam "probe: $name"
  # '...[HEAD^1]' = packages changed since the previous commit plus their dependents.
  turbo run build test lint --filter="...[HEAD^1]" --dry=json \
    | jq '[.tasks[].package] | unique' > "$OUT/$name.json"
  git reset -q --hard HEAD^1           # undo the probe before the next scenario
done
git switch -q - && git branch -D baseline-scratch

ls "$OUT"

A script that records each package's scripts, the current turbo.json, and the exact package set Turborepo would run for each change scenario, giving you files to diff against Nx's affected output after migration.

How does nx init work in an existing Lerna or Turborepo workspace?

With the baseline recorded, run nx init from the repository root on a dedicated branch. In an existing package-manager workspace it does three things that matter. It adds nx as a dev dependency. It creates an nx.json at the root. It reads each package's package.json and treats the scripts it finds as runnable targets, so build, test, and lint become Nx tasks without any per-project configuration. The prompts it shows, such as which scripts are cacheable, differ between versions. Answer them conservatively and plan to refine the choices later.

Commit the generated output as-is, before you edit anything by hand. This gives you a clean diff showing exactly what the tool changed, which makes review easier and makes mistakes easy to attribute. Check the lockfile churn, any new entries in .gitignore, and the task defaults written to nx.json. Then make your corrections as separate commits on top.

Lerna workspaces

Lerna can delegate task running to Nx. This lets you migrate in two stages: task execution moves to Nx first, and versioning and publishing stay with Lerna. Release workflows tend to be the most fragile part of a monorepo, so leaving them untouched while you validate caching and the project graph lowers your risk.

The cost is that you now run two tools at once. You have two sets of configuration to maintain, and engineers need to know which tool is authoritative for what. Write that split down, for example "Nx runs tasks, Lerna owns releases." Without it, someone will eventually change the wrong file and wonder why nothing happened.

git checkout -b chore/nx-init

npx nx@latest init

git add -A
git commit -m "chore: run nx init (unedited output)"

git show --stat HEAD                 # which files were touched
git show HEAD -- nx.json             # task defaults and cache settings
git show HEAD -- .gitignore          # new ignore entries (e.g. cache dirs)
git show HEAD -- package.json        # nx added as a devDependency
git show --stat HEAD -- '*lock*'     # size of lockfile churn

npx nx show projects

Running nx init on a dedicated branch, committing its output unedited, and reviewing the diff before any manual changes.

How do you translate turbo.json pipelines and backend cache inputs into nx.json?

Treat turbo.json as a specification to translate, not a file to convert mechanically. Most keys have a direct Nx equivalent under targetDefaults or per-project targets. The details differ, so check each task.

Turborepo (turbo.json) Nx (nx.json) Semantic check
dependsOn: ["^build"] dependsOn: ["^build"] Same: upstream projects build first
outputs: ["dist/**"] outputs: ["{projectRoot}/dist"] Turbo paths are package-relative. Nx needs the {projectRoot} token
inputs inputs / namedInputs Define production once and reuse it
env: ["DATABASE_URL"] inputs: [{ "env": "DATABASE_URL" }] Must be listed explicitly
globalDependencies sharedGlobals named input Root config such as tsconfig.base.json

Backend outputs go beyond dist. Generated OpenAPI clients, Prisma clients and protobuf stubs are all outputs, and their spec files are inputs. Prisma writes into node_modules by default, which no cache restores reliably. Configure a project-local output path and declare it.

The cache works like this:

src + schema.prisma + .env keys + tsconfig ──► hash hash hit ──► restore declared outputs ──► skip execution

A missing input causes a false cache hit: the schema changed, the hash didn't, and the old client is restored. A missing output is worse. The task reports success, but the build artifact never lands on disk. In both cases CI stays green while the deployed service runs stale code.

{
  "namedInputs": {
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/jest.config.ts"
    ]
  },
  "targetDefaults": {
    "prisma-generate": {
      "cache": true,
      "inputs": ["{projectRoot}/prisma/schema.prisma"],
      "outputs": ["{projectRoot}/src/generated/prisma"]
    },
    "openapi-generate": {
      "cache": true,
      "inputs": ["{projectRoot}/openapi.yaml"],
      "outputs": ["{projectRoot}/src/generated/api-client"]
    },
    "build": {
      "cache": true,
      "dependsOn": ["^build", "prisma-generate", "openapi-generate"],
      "inputs": [
        "production",
        "^production",
        { "env": "DATABASE_URL" },
        { "env": "API_BASE_URL" }
      ],
      "outputs": ["{projectRoot}/dist"]
    }
  }
}

An nx.json translating a turbo.json pipeline: build depends on upstream builds and local codegen, spec files are codegen inputs, env vars are explicit inputs, and the dist folder plus project-local Prisma and OpenAPI output are declared outputs.

How do you verify the project graph and affected results before trusting them?

Start with nx graph and read it against your inventory rather than skimming it. Every internal dependency you listed should appear as an edge. A missing edge usually means a package imports a sibling through a path alias or a relative path instead of a declared dependency, which Turborepo and Lerna may also have missed but which now determines what Nx skips.

Next, replay the change scenarios from your inventory. Check out each historical commit range and run nx affected -t build test --base=<before> --head=<after>. Then list what the old tool would have selected for the same range, using its dry-run or --since output. Treat every difference as a question to resolve. Nx selecting more projects is usually safe but slower. Nx selecting fewer is the case to investigate before you rely on it in CI.

To catch stale outputs, build a target twice from a clean tree. Run it once with --skip-nx-cache and once from a warm cache, then diff the output directories. Pay special attention to generated clients, bundled config, and anything read from environment variables. Identical hashes with different artifacts mean an input is missing from the target definition.

Local and CI results can diverge. The Nx daemon keeps a long-lived graph that can lag behind file changes, and CI runners start with a cold, different environment. When results look wrong:

  • Run nx reset to clear the cache and stop the daemon.
  • Rerun with NX_DAEMON=false to rule the daemon out.
  • Set the daemon off in CI if ephemeral runners gain nothing from it.
#!/usr/bin/env bash
set -euo pipefail
BASE=a1b2c3d            # commit before the historical change (placeholder)
HEAD=e4f5a6b            # commit after the change (placeholder)
PROJECT=web             # project whose artifacts we check for staleness
OUT=dist/apps/web       # that project's build output directory
export NX_DAEMON=false  # rule out a stale daemon graph during verification

git checkout --quiet "$HEAD"

npx nx graph --file=graph.json

npx nx show projects --affected --base="$BASE" --head="$HEAD" | sort > nx-affected.txt
npx lerna ls --since "$BASE" --all | sort > old-affected.txt  # Turborepo: use its dry-run output instead
diff nx-affected.txt old-affected.txt || true

rm -rf "$OUT"
npx nx run "$PROJECT:build"
rm -rf /tmp/cached-out && cp -r "$OUT" /tmp/cached-out

rm -rf "$OUT"
npx nx run "$PROJECT:build" --skip-nx-cache
diff -r /tmp/cached-out "$OUT" && echo "cached and fresh artifacts match"

A verification script that exports the Nx project graph, diffs Nx's affected projects against Lerna's selection for a historical commit range, and compares cached and cache-free build artifacts with the daemon disabled.

How should CI change when you migrate Turborepo to Nx?

Replace each turbo run build or lerna run build call with one of two Nx commands, depending on the job. Use nx run-many -t build where you need every project built, such as nightly or release jobs. Use nx affected -t build test on pull requests and pushes to main. Affected detection diffs two commits, so set them explicitly with --base and --head, or with the NX_BASE and NX_HEAD environment variables:

  • Pull requests: set base to the merge-base with the target branch.
  • Main: set base to the last commit where CI succeeded, not HEAD~1. Otherwise a push containing several commits silently skips projects changed in the earlier ones.

The checkout needs enough git history to contain the base commit. Most CI systems clone shallowly by default. When the base SHA is missing, the diff cannot run, and depending on your setup the job either fails or treats every project as affected. Squash-merge workflows cause a related problem. A recorded base that pointed at a feature-branch commit becomes unreachable after the squash, and the same fallback follows.

Start with the local cache only, scoped per runner. Once affected results match expectations, evaluate a shared remote cache. Treat it as a trust boundary, because anyone who can write an entry can plant an artifact that a later build restores without rebuilding. Let only main and release pipelines write to the cache. Give pull-request and fork builds read-only access or a separate cache, and never publish releases from artifacts written by untrusted contexts.

Illustrative estimate. Assume 40 projects, a 30-minute full build, 80 pull-request runs a week, and a typical change touching 6 projects. Running only affected projects gives about 4.5 minutes per run, or roughly 360 runner-minutes a week instead of 2,400. This assumes build time scales linearly with project count. A change to a shared library affects many dependents, so the real savings will be smaller than this.

How can Nx save engineering time?

Runner-minutes are the easiest saving to measure, but they are not the main one. The larger saving is engineer time spent waiting, rerunning, and debugging. Nx reduces it in several ways:

  • Shorter feedback loops on pull requests. With affected detection, a change to one service runs that service's tasks and its dependents, not the whole repo. Engineers wait less before they can review or merge, and they switch context less while waiting.
  • Cache hits on local machines. Switching back to a branch you already built, or running tests that were unchanged since the last run, replays results from the local cache. With a shared remote cache, a build CI has already done can be restored on a developer machine.
  • Fewer full-pipeline reruns. When a flaky test fails and the job is retried, tasks that already succeeded with the same hash come from cache. Only the failed work runs again.
  • One command surface. nx run, nx affected and nx run-many work the same way in every project. Engineers new to the repo do not have to learn per-package script conventions or remember which filter syntax selects dependents.
  • Earlier detection of coupling. Module boundary rules fail lint when one service imports another service's internals. The problem shows up at review time, not during a later refactor or an incident.
  • A visible dependency graph. nx graph answers "what depends on this library?" directly. That question otherwise takes a grep across the repo and some guesswork.

Count the migration's own cost against these gains: the inventory, the translation of turbo.json, the verification work, and the ongoing upkeep of nx.json and of the Nx version itself.

Illustrative estimate. Assume 15 engineers who each open 5 pull requests a week, and assume affected runs cut the wait for the first CI result from an assumed 30 minutes to an assumed 8. If an assumed quarter of that saved wait would otherwise have been lost to idle time or context switching, the team recovers about 15 × 5 × 22 × 0.25 ≈ 410 minutes, or roughly 7 engineer-hours a week. Also assume the migration and verification take an assumed 60 engineer-hours. On those inputs it pays back in around two months. Change any input and the result moves a lot. Do this calculation with your own pre-migration baseline numbers rather than relying on these.

When should you adopt Nx plugins and module boundaries, and when should you roll back?

Once CI runs on Nx without surprises for a few weeks, you can consider the optional layer. Nx plugins such as @nx/js or @nx/node can infer targets from existing config files, so you stop duplicating build and test definitions. The @nx/enforce-module-boundaries ESLint rule uses project tags, for example type:service and type:lib, to block one service from importing another service's internals. In a backend monorepo this is often the most useful long-term feature, because it prevents accidental coupling between deployable units.

The cost is lock-in. Plugins add configuration surface and tie you to Nx's upgrade and migration cycle. A workspace that still runs plain package.json scripts through nx run can leave by deleting nx.json. A plugin-heavy workspace cannot leave that easily.

For rollback, keep turbo.json or lerna.json in the repo for a fixed window, such as two release cycles, and keep the old CI job definition disabled but intact. Switch CI back if you see either of these:

  • A cache hit that served a stale artifact, which is a cache correctness failure.
  • A sustained rise in median pipeline time over your pre-migration baseline.

Delete the old config only after the window closes cleanly.

Situation Decision
Scripts run through Nx, affected results match manual checks Proceed; add tags and boundaries
Unexplained cache misses or wrong affected sets Pause; fix inputs before adding plugins
Few projects, fast CI, no cross-service coupling issues Avoid; the exit cost isn't worth it

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to migrate turborepo to nx, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.