How nx migrate works: plan first, then apply
nx migrate splits an upgrade into two separate commands so you can inspect what will change before anything touches your source code.
Phase one: plan
Running nx migrate latest, or passing a specific version, resolves the target Nx release and the versions of related packages that release expects. It then updates the version ranges in package.json. If any packages ship migrations for the versions you are crossing, it also writes a migrations.json file at the workspace root listing them. It does not install dependencies or edit application code. At this point the diff is small and reviewable: changed version strings plus a list of named transformations, each with a description and the package that owns it.
Phase two: apply
After you install the updated dependencies with your package manager, nx migrate --run-migrations reads migrations.json and executes each entry in order. The migrations come from the newly installed packages, which is why installation has to happen first. Each migration is code that rewrites configuration files, renames options, or adjusts imports to match the new APIs.
Why the split matters
Treat the upgrade as a pipeline with checkpoints:
- Generate the plan and commit it.
- Install the dependencies.
- Run the migrations and commit the result.
- Verify with builds, tests and lint.
Each stage produces its own diff. If something breaks, you can tell whether the cause was a dependency bump or a code transformation, and you can revert one without losing the other.
# Phase one: plan the upgrade.
# Updates version ranges in package.json and writes migrations.json.
# No dependencies are installed and no source files are edited.
npx nx migrate latest
# Or pin a specific target instead of latest:
# npx nx migrate 20.0.0
# Review the plan before anything else happens.
git diff package.json
cat migrations.json
# Checkpoint 1: commit the plan on its own.
git add package.json migrations.json
git commit -m "chore: plan nx upgrade (nx migrate)"
# Install the new versions so the migration code is available locally.
npm install
# Phase two: apply the recorded transformations in order.
npx nx migrate --run-migrations
# Checkpoint 2: commit the code changes made by migrations separately.
git add -A
git commit -m "chore: apply nx migrations"
# Verify the result across the workspace.
npx nx run-many -t build test lint
# Once verification passes, migrations.json is no longer needed.
git rm migrations.json
git commit -m "chore: remove applied migrations.json"
A shell walkthrough of the plan, install, apply and verify pipeline, with a separate commit at each checkpoint so dependency bumps and code transformations can be reviewed or reverted independently.
Who should use this approach and when
The staged approach pays off most for engineers who own a monorepo where many teams depend on the same toolchain. If your workspace has dozens of projects, a mix of official Nx plugins and third-party plugins, and a shared CI pipeline that every merge goes through, a broken upgrade blocks everyone at once. Splitting the work into reviewable stages keeps that risk contained. Platform and backend engineers usually fit this description, because they inherit the build configuration, executor options and caching setup that application teams rarely touch.
When to use it
- You are several majors behind. Migrations are written assuming the workspace is at the previous major. If you jump across several majors at once, transformations stack up and their interactions become hard to debug. Moving one major at a time keeps each set of migrations working on the code shape it expects.
- A new major was just released. Early adopters find edge cases that the published migrations do not cover. A staged upgrade lets you stop at the latest stable point and wait for patch releases before taking the final step.
When to avoid or postpone it
- Critical third-party plugins lag behind. If a plugin your builds depend on has not declared support for the target version, upgrading Nx forces either a fork or a broken pipeline. Wait for the plugin to catch up.
- A large feature merge is in flight. Migrations rewrite configuration and sometimes source files across the repo. Landing them alongside a long-lived branch causes painful conflicts. Schedule the upgrade after the merge.
Preparing the workspace and reading migrations.json
Start from a clean working tree on a dedicated branch cut from the latest main. Run the full build, lint and test suite in CI before touching any versions, and record the results. If anything is already failing, fix it or write it down. Otherwise you can't tell whether a post-migration failure came from the upgrade or was already there. Clear the Nx cache too, so the baseline isn't served from cached results.
Next, check plugin compatibility. For third-party plugins, look at their declared peer dependencies on nx and @nx/devkit. A plugin pinned to an older major will either block the install or fail at runtime. Local plugins and custom generators in tools/ or a plugins folder are easy to forget. They call devkit APIs directly, and those APIs get deprecated and removed between majors.
After nx migrate generates migrations.json, read it before running anything. Each entry records:
- package: which plugin owns the migration
- name: a stable identifier for the migration
- description: what it intends to change
- version: the release that introduced it
Group the entries by package:
- Core
nxentries usually touchnx.json, project configuration and task inputs. - Framework plugins, such as the React, Angular or Node integrations, often rewrite source imports and builder options.
- Tooling plugins for Jest, ESLint or bundlers rewrite config files that teams have often hand-edited.
Those hand-edited files need the most scrutiny, because a migration written against the default template may not match your customizations. Also flag any description that mentions removing an option, renaming an executor or changing defaults. These are the changes that pass silently and only show up later as behavior differences.
#!/usr/bin/env bash
set -euo pipefail
# 1. Start from a clean tree on a dedicated branch cut from the latest main
git switch main && git pull --ff-only
test -z "$(git status --porcelain)" || { echo "Working tree not clean"; exit 1; }
git switch -c chore/nx-upgrade
# 2. Clear the Nx cache so the baseline is a real run, then record it
npx nx reset
npx nx run-many -t build lint test --skip-nx-cache 2>&1 | tee baseline-before-migrate.log
# 3. Check what a third-party plugin declares as compatible (placeholder name)
npm view example-nx-plugin@latest peerDependencies
# 4. Update package.json and generate migrations.json, but don't run migrations yet
npx nx migrate latest
# 5. Count migrations per owning package (core, framework, tooling plugins)
jq -r '.migrations | group_by(.package)[]
| "\(.[0].package): \(length) migration(s)"' migrations.json
# 6. Flag entries whose description suggests removed options,
# renamed executors or changed defaults. Review these by hand.
jq -r '.migrations[]
| select((.description // "") | test("remov|renam|default|deprecat"; "i"))
| "\(.package)\t\(.version)\t\(.name)\n \(.description)"' migrations.json
Shows the pre-upgrade workflow as a shell script: it creates a clean branch, records an uncached build, lint and test baseline, checks a plugin's peer dependencies and generates migrations.json, then uses jq to group the migrations by package and flag the ones that need closer review.
Implementing the upgrade one major version at a time
Treat each major version as its own loop, and finish the loop before starting the next one. The smaller the step, the fewer migrations can interact, and the easier it is to tell which one caused a failure.
The loop
- Generate for the next major only. Run
nx migratewith the next major as the target, notlatest. This rewritespackage.jsonand writes a freshmigrations.jsonscoped to that one step. - Install dependencies. Use your normal package manager command so the lockfile reflects the new versions.
- Review and commit the lockfile diff on its own. Write a commit message that names the cause, such as "bump Nx to next major and aligned plugins." A lockfile change often pulls in transitive upgrades you did not ask for. Isolating it means you can later tell a dependency problem apart from a code transformation problem.
- Run the migrations with
nx migrate --run-migrations. - Verify with the build, lint and test targets across affected projects before moving on.
- Commit.
Commit per migration
One commit for all migrations combined hides which transformation broke something. Check nx migrate --help on your installed version for a flag that creates one commit per migration. If you don't find one, run and commit the migrations by hand. Either way, git bisect can then land on the exact migration at fault, and git revert can undo it without discarding the rest.
Clean up
Once the migrations are verified, delete migrations.json and commit that deletion. A leftover file can be re-run against the next major, or confuse whoever generates the next one.
# One upgrade loop: current major -> next major. Repeat the whole loop per major.
set -euo pipefail
NEXT_MAJOR=20 # placeholder: set this to the major after your current one, not "latest"
# 1. Generate for the next major only. Updates package.json, writes migrations.json.
npx nx migrate "nx@${NEXT_MAJOR}"
# 2. Install so the lockfile reflects the new versions.
npm install
# 3. Review the lockfile diff for unexpected transitive upgrades, then commit it alone.
git diff --stat package.json package-lock.json
git add package.json package-lock.json migrations.json
git commit -m "chore: bump Nx to ${NEXT_MAJOR} and aligned plugins (generated by nx migrate)"
# 4. Run the migrations. First check whether your version can commit per migration.
if npx nx migrate --help | grep -q -- '--create-commits'; then
# One commit per migration, so git bisect can find a bad transformation.
npx nx migrate --run-migrations --create-commits
else
# Fallback: run the migrations, then split the changes into one commit per
# migration by hand, using the migration names listed in migrations.json.
npx nx migrate --run-migrations
fi
# 5. Verify before starting the next major.
npx nx affected -t build lint test --base=HEAD~1
# If something breaks: git bisect to the bad migration, then git revert <sha>.
# 6. Clean up once verified, so the file is not re-run against the next major.
git rm migrations.json
git commit -m "chore: remove migrations.json after Nx ${NEXT_MAJOR} upgrade"
A shell walkthrough of one major-version upgrade loop. It generates migrations for the next major only, commits the lockfile change separately, runs migrations with one commit each where the installed Nx supports it, verifies, and then removes migrations.json.
Verifying results and fixing breaking changes migrations miss
Run everything, not just what's affected
After a migration, the affected graph is not a reliable guide. Migrations rewrite nx.json, root config, and shared tooling, so almost every project is touched, and some breakages appear only in projects whose source never changed. Run nx reset first to clear the local cache and daemon state. Otherwise, cached outputs from the old version can replay as green results for tasks that would now fail. Then run build, lint, test, and e2e across every project with nx run-many.
Read the diffs to nx.json and each project.json line by line. Look for targetDefaults, inputs, and namedInputs changes in particular, because these alter cache keys and task dependencies without producing any error. In CI, confirm that the first run after the upgrade misses the remote cache rather than restoring artifacts built by the previous version. Then confirm that later runs hit the cache again.
Fix what migrations can't
Migrations handle mechanical rewrites. They do not apply judgment calls. Read the release notes for every major version you crossed, not only the target version, and look for three things:
- Deprecated options: these still work but print warnings, and they will be removed in a later release.
- Executor and generator renames: these are often split across packages.
- Changed defaults: behavior shifts even though your config is unchanged.
Custom and local plugins need the most attention. No migration updates your own code against Nx devkit API changes, so update those calls by hand. Then run the plugin's own tests, including generator tests against a virtual tree.
Put each fix in its own commit. That way a bisect points to one cause, and a single change can be reverted without undoing the upgrade.
#!/usr/bin/env bash
# Post-migration verification on the upgrade branch.
# Assumes the pre-upgrade state is on 'main' and the plugin project is named 'workspace-plugin'.
set -euo pipefail
BASE=main
# 1. Clear the local cache and stop the daemon so old-version outputs
# cannot replay as passing results.
npx nx reset
# 2. Run every target on every project, not just affected ones.
# --skip-nx-cache forces real execution on this first pass.
npx nx run-many -t build lint test e2e --skip-nx-cache
# 3. Review config changes that alter cache keys or task ordering
# without raising errors. Look at targetDefaults, inputs, namedInputs.
git diff "$BASE" -- nx.json '*project.json'
# 4. Run the local plugin's own tests after updating devkit calls by hand.
npx nx test workspace-plugin --skip-nx-cache
# 5. Commit each manual fix on its own so bisect and revert stay precise.
git add libs/ui/project.json
git commit -m "fix(ui): replace deprecated executor option after Nx upgrade"
git add tools/workspace-plugin/src
git commit -m "fix(workspace-plugin): update devkit API calls for new Nx major"
A post-migration script that resets the Nx cache, runs all targets across every project without the cache, diffs workspace configuration against the pre-upgrade branch, tests a local plugin, and commits each manual fix separately.
Alternatives: single-jump upgrades, manual bumps and pinning
The staged pipeline is not the only way to move an Nx workspace forward. Three alternatives come up often, and each trades away a different property.
| Approach | Reviewability | Bisectability | Config drift risk | CI cost |
|---|---|---|---|---|
Staged nx migrate, one major at a time |
High: small, scoped diffs | High: one cause per commit | Low | Several full runs |
| Single jump to latest | Low: one large diff | Poor: everything lands at once | Low to moderate | One run, but long debugging |
| Manual version bumps, no migrations | Deceptively high: tiny diff | Poor | High | Low until something breaks |
| Pinning and deferring | N/A | N/A | Grows over time | None now, more later |
Single-jump upgrades still run the migrations, so the config stays mostly correct. The cost shows up at review time. Generated changes from several majors mix together, and a failing test cannot be traced to one migration. This is reasonable for small workspaces with few projects and good test coverage.
Manual bumps change package.json and skip migrations.json. The diff looks clean because the config rewrites the new version expects were never applied. Failures appear later and look unrelated. Use this only for patch releases that ship no migrations.
Pinning is the honest choice when a third-party plugin has not caught up with a new Nx major. Record which plugin is blocking the upgrade, then check its releases on a schedule. Each deferred major adds work to the eventual upgrade, so don't let a pin become permanent.
Impact on cost and engineering time
Most of the cost of an Nx upgrade is engineering time spent reviewing, debugging and untangling changes. Compute is a smaller share. How you split the upgrade decides where that time goes.
| Approach | CI runs | Review size | Conflict risk with feature branches | Revert granularity |
|---|---|---|---|---|
| One major at a time, per-migration commits | More | Small, focused diffs | Low if the branch is short-lived | A single migration |
| Single jump, one squashed commit | Fewer | One large diff | High | The whole upgrade |
Migrations rewrite config and source across many projects, so their diffs collide with open feature branches. Schedule upgrades for a quiet window and merge within a day or two. Peer dependency bumps, such as a framework, TypeScript or a test runner, widen the scope. Each one adds its own breakage, so budget review time for it separately.
Verification usually runs uncached because lockfile and config changes invalidate the cache. Expect full-length CI runs and plan for them.
Never leave an upgrade half-applied. If package.json is bumped but migrations haven't run, the workspace is out of sync and fails in confusing ways for the next person. Either finish the upgrade or revert it completely.
Illustrative estimate (assumed numbers, not a benchmark):
Time saved = failures × (t_squashed − t_per_commit) − extra CI wait
Assumed inputs:
- 3 failures surface during the upgrade.
- Isolating each failure takes 60 minutes in a squashed commit and 15 minutes with per-migration commits.
- Engineers wait on 2 extra CI runs at about 10 minutes of attention each.
Time saved = 3 × 45 − 20 = 115 minutes per upgrade.
A regular cadence keeps the failure count low, so each upgrade stays small and easy to revert.
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 nx migrate, 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
.envby 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.