What nx migrate does and how its two phases work
nx migrate is the Nx command for moving a workspace to a newer version of Nx and its plugins. Nx plugins ship their own migrations, which are scripted edits to source code, project configuration and nx.json that account for breaking changes in a release. nx migrate finds the migrations that apply to the versions you are moving between and runs them in order, so you don't have to work through release notes by hand.
The work happens in two separate phases.
Phase one: plan the upgrade
Running nx migrate latest, or nx migrate <version> to target a specific release, changes only two things. It rewrites the versions in package.json. If any migrations apply, it also writes a migrations.json file that lists each migration by package, name and description. Your source tree is not modified. Review the plan with git diff package.json, read through migrations.json, and then install dependencies with your package manager.
Phase two: apply the edits
nx migrate --run-migrations reads migrations.json and performs the code and config changes. Inspect the result with git status and git diff. Some migrations also adjust dependencies, so run another install afterwards.
The two phases are kept apart on purpose. The gap between them is where you check that the version changes make sense, that third-party plugins are still compatible, and that every listed migration is one you want. Most upgrade failures attributed to plugin changes come from running both phases back to back without that review. Nx itself is rarely the cause.
# Start from a clean working tree so every change below is attributable to the upgrade
git status
# --- Phase one: plan the upgrade (source tree is not touched) ---
# Updates versions in package.json and writes migrations.json if any migrations apply
npx nx migrate latest
# or pin a specific target release instead of latest:
# npx nx migrate 19.8.0
# Review the planned version bumps, including third-party plugins
git diff package.json
# Read every listed migration (package, name, description) before applying anything.
# Remove entries you do not want to run.
cat migrations.json
# Install the new versions so the migration code is available
npm install
# --- Phase two: apply the code and config edits ---
npx nx migrate --run-migrations
# Inspect what the migrations changed in source, project config and nx.json
git status
git diff
# Some migrations adjust dependencies, so install again
npm install
# Commit once the diff has been reviewed and the workspace builds and tests cleanly
git add -A
git commit -m "chore: upgrade nx and apply migrations"
Running nx migrate in two phases with a git review step after planning and after applying migrations; 19.8.0 is a placeholder target version.
Who should follow this checklist and when to use it
This checklist is for backend and platform engineers who own an Nx monorepo and are accountable when it breaks. It applies whether your workspace relies only on the official @nx/* plugins, mixes in community plugins, or includes in-repo plugins built by your own team. Each of these behaves differently during an upgrade. Official plugins ship migrations that Nx coordinates. Community plugins may or may not provide them and may lag behind core releases. In-repo plugins get no migrations unless you write them yourself.
When to use it
- Routine minor upgrades. These are low risk, but running the full process keeps the habit in place and makes it obvious when a minor release quietly changes generator defaults or executor options.
- Major version moves. Breaking changes cluster here, so reviewing migrations and checking plugin compatibility pays off most.
- Catching up on a workspace that has fallen behind. Several releases of accumulated migrations interact in ways that are hard to debug in a single jump. Stepping through intermediate versions keeps each diff reviewable.
When to postpone
- Release freezes. An upgrade changes build tooling under every project at once. Do not do it when you cannot absorb a regression.
- A CI baseline that is not green. If tests already fail, you cannot tell which failures the upgrade caused.
- A blocking third-party plugin with no compatible release. Wait, pin the current version, or plan to replace the plugin. Forcing the upgrade through usually means patching someone else's code under time pressure.
Pre-upgrade checklist: clean baseline, branch and recorded versions
Do these steps before generating any migrations. Each one keeps later failures traceable.
1. Start from a clean working tree
Run git status --porcelain and confirm it prints nothing. nx migrate rewrites package.json, configuration files and source code. If uncommitted local edits are mixed in, you cannot tell which changes came from the migration and which were already there. Commit or stash everything first, including untracked generated files.
2. Create a dedicated branch
Use something like git switch -c chore/nx-upgrade. Keep feature work off this branch. A focused branch keeps the diff reviewable and lets you drop the whole attempt without affecting anyone else.
3. Record current versions
Run npx nx report. It prints the installed versions of nx and every @nx/* plugin, and it may also list detected community plugins. Save the output in the pull request description or a scratch file. To cross-check against the package manager, run npm ls --depth=0, or pnpm list --depth 0 or yarn list --depth=0, and filter for nx and @nx/. Any mismatch between plugin versions at this stage is a problem you already have, not one the upgrade introduced.
4. Confirm CI is green on the baseline
Run the full pipeline on the commit you are branching from. If tests are already red or flaky, any failure after migrating is ambiguous: you cannot say whether the upgrade caused it. Fix or quarantine those failures first.
5. Treat versions and the lockfile as your rollback plan
The recorded versions and the committed lockfile define a state you can restore exactly. Rolling back means checking out the baseline package.json and lockfile, then running a clean install.
#!/usr/bin/env bash
# Pre-upgrade baseline for an nx migrate run.
# Stop on the first failure so a dirty or broken baseline is never ignored.
set -euo pipefail
# 1. Refuse to continue if anything is uncommitted or untracked.
if [ -n "$(git status --porcelain)" ]; then
echo "Working tree is not clean; commit or stash first." >&2
exit 1
fi
# Remember the baseline commit; CI must be green on this exact SHA.
BASELINE_SHA=$(git rev-parse HEAD)
echo "Baseline commit: $BASELINE_SHA"
# 2. Isolate the upgrade on its own branch.
git switch -c chore/nx-upgrade
# 3. Record installed versions of nx and every @nx/* plugin.
mkdir -p .upgrade-notes
npx nx report > .upgrade-notes/nx-report-before.txt
# Cross-check against the package manager (use the pnpm or yarn equivalent if needed).
# '|| true' because npm ls exits non-zero on peer/dependency problems; we still want the output.
npm ls --depth=0 2>&1 | grep -E 'nx@|@nx/' > .upgrade-notes/npm-ls-before.txt || true
cat .upgrade-notes/npm-ls-before.txt
# 5. Rollback recipe: restore the baseline manifest and lockfile, then clean install.
# git checkout "$BASELINE_SHA" -- package.json package-lock.json
# npm ci
A shell script that checks for a clean working tree, records the baseline commit, creates a dedicated upgrade branch, saves the current nx and @nx/ versions, and notes the commands to restore the baseline lockfile for rollback.*
Reviewing migrations.json and keeping plugin versions aligned
After the first phase, migrations.json sits at the workspace root as a plain list under a migrations key. Each entry names the package that owns it, a name, the version that introduced it and a short description. Read every description before running anything. It is the only summary of what the migration will change.
Flag entries that touch config, executors and generators
Pay closest attention to entries that rewrite nx.json, project.json or tool configs such as Jest, ESLint or TypeScript. Also watch for entries that rename executor options or change generator defaults. These produce diffs you will need to review line by line, so note them now.
Remove entries only on purpose
You can delete an entry you have decided not to apply. Record which entry you removed and why in the commit message or the PR. The risk is that migrations are written to run in order, and a later migration may assume an earlier one already reshaped a file. If you skip one, a later one can fail or quietly produce a wrong result.
Keep plugin versions aligned
Nx core and the official @nx/* plugins are released together and should stay on the same version. When versions are mixed, a plugin's executor can be loaded against a core version whose option schema it does not expect. This shows up as unknown-option or schema validation errors at run time.
Community plugins are handled separately. Check each one's compatibility notes. Pin it to its current version if it lags behind core. If it publishes no migrations, make the config edits yourself in their own commit.
{
"migrations": [
{
"package": "nx",
"name": "example-update-nx-json-target-defaults",
"version": "0.0.0-example",
"description": "Moves per-project target settings into targetDefaults in nx.json.",
"implementation": "./src/migrations/example-update-nx-json-target-defaults"
},
{
"package": "@nx/jest",
"name": "example-rename-jest-executor-option",
"version": "0.0.0-example",
"description": "Renames a Jest executor option in project.json and updates jest.config files.",
"implementation": "./src/migrations/example-rename-jest-executor-option"
},
{
"package": "@nx/react",
"name": "example-change-component-generator-default",
"version": "0.0.0-example",
"description": "Changes a default for the component generator in nx.json generators config.",
"implementation": "./src/migrations/example-change-component-generator-default"
}
]
}
An illustrative migrations.json with placeholder names and versions, showing the package, name, version and description fields to review, with entries that touch nx.json, an executor option and a generator default.
Running migrations step by step and committing in layers
Do not jump straight from your current major version to the newest one. Each major's migrations assume the workspace is in the state the previous major left it in. Skipping majors makes failures harder to attribute to a specific change. Target the latest minor of the next major, finish that step completely, and only then start the next.
One step, in order
- Run
nx migrate nx@<next-major>to updatepackage.jsonand generatemigrations.json. - Run your package manager's normal install command. Do not delete the lockfile first.
- Commit
package.jsonand the lockfile together as the first commit. - Run
nx migrate --run-migrations, then commit everything it changed as a second commit. Keepmigrations.jsonin that commit so reviewers can see which migrations produced the diff. - Run builds, tests and lint across the workspace, for example with
nx run-many --target=build. Commit each manual fix separately, with a message that says what broke. - Delete
migrations.jsonin a final commit before moving to the next major.
Why layered commits help
This layering lets a reviewer skim the generated commit and spend their attention on the hand-written fixes, where real risk lives. It also means a bad migration can be reverted without losing your own changes.
Controlling lockfile churn
An install can also bump transitive dependencies unrelated to Nx. Before committing the lockfile, check its diff for packages outside the Nx and plugin dependency trees. If unrelated packages moved, restore the previous lockfile and reinstall so that only the changed manifest entries resolve. Defer other updates to a separate pull request. Otherwise a regression from an unrelated bump will look like an Nx breakage.
#!/usr/bin/env bash
# One migration step: workspace on Nx 19 -> latest 20.x. Repeat per major.
set -euo pipefail
# Placeholder: set this to the latest published 20.x release.
TARGET="20.x.y"
# 1. Update package.json and generate migrations.json (no code changes yet).
npx nx migrate "nx@${TARGET}"
# 2. Install against the existing lockfile. Do not delete it first.
npm install
# Review lockfile churn. Packages outside the Nx/plugin trees are a red flag.
git diff --stat -- package-lock.json
git diff -- package-lock.json | grep '^[+-] *"node_modules/' | sort -u | less
# If unrelated packages moved:
# git checkout -- package-lock.json && npm install
# 3. Commit 1: dependency bump only.
git add package.json package-lock.json
git commit -m "chore(deps): bump nx to ${TARGET}"
# 4. Commit 2: generated changes, with migrations.json kept for reviewers.
npx nx migrate --run-migrations
git add -A
git commit -m "chore: apply nx ${TARGET} migrations"
# 5. Verify. Commit each manual fix separately with a message naming what broke.
npx nx run-many --targets=build,test,lint
# git commit -m "fix(app-shell): replace removed executor option"
# 6. Final commit for this step, then start the next major.
git rm migrations.json
git commit -m "chore: remove migrations.json after nx ${TARGET} upgrade"
A single Nx major-version step with npm, from migrate through verification, producing separate commits for the dependency bump, generated migrations, manual fixes and migrations.json cleanup, with a lockfile churn check before the first commit.
Verification after migrating and what migrations do not cover
Verify the whole workspace
- Reinstall dependencies from scratch. Delete
node_modulesand install from the updated lockfile. Packages left over from the old install can hide missing peer dependencies or let stale versions resolve. - Run
nx reset. This clears the local cache and stops the Nx daemon. Cached results and the project graph were computed under the old plugin versions, and reusing them can report success for tasks that never ran against the new code. - Build, test and lint every project, not only affected ones. Use
nx run-manyinstead ofnx affected. Affected detection compares your branch against the base branch, and an upgrade changes shared configuration such asnx.json, root tsconfig files and the lockfile. Depending on how inputs are configured, that can mark everything as affected or miss projects whose behaviour changed indirectly. Running all targets removes the guesswork. - Re-test custom and local plugins. Generators and executors you wrote yourself import from the devkit. Type-check them against the new type definitions and run their tests. Migrations update usage in your projects, but they do not update your own plugin code to match changed APIs.
Check what migrations skip
Review these by hand: bespoke shell or Node scripts that call Nx or read its config, Dockerfiles that pin Node or package-manager versions, CI pipeline definitions, and custom executors outside a plugin package.
Rolling back
If verification fails and you cannot fix it quickly, revert the branch. Restore the package manifests and lockfile to the versions you recorded before starting, reinstall, and run nx reset again.
Alternatives and impact on cost and engineering time
Every alternative to the reviewed, layered process saves effort up front and costs more later in debugging, risk, or maintenance.
| Approach | Effort | CI cost | Debuggability | Risk |
|---|---|---|---|---|
| Reviewed two-phase, layered commits | Moderate | Higher: full runs per layer or per major | High: a failure maps to one commit | Low |
| Run all migrations unreviewed | Low | Lower at first, reruns later | Poor: one diff mixes every change | High |
| One large multi-major jump | Low at first | One large run | Poor | High: migrations may expect intermediate states |
| Hand-edit versions without nx migrate | High | Varies | Poor: no code migrations run | High: config and source changes are missed |
| Pin, replace or vendor a lagging plugin | Ongoing | Low | Medium | Medium: drift grows with each release |
Pinning or vendoring is a reasonable short-term way to keep the rest of the workspace moving. Treat it as debt with an owner and a review date.
The layered process costs more CI time, because you run all projects for each layer and open one PR per major. It saves engineer time, which usually costs more. When a test fails, the commit that caused it is already isolated, and reviewers read small diffs where each change has a single purpose.
Illustrative estimate (assumed numbers, not a benchmark)
Cost = (CI runs × minutes per run × CI rate) + (engineer hours × hourly rate)
Assumed inputs: 30-minute full run, $0.10 per CI minute, $100 per engineer hour.
- Layered: 6 runs and 3 hours of focused debugging. That is (6 × 30 × 0.10) + (3 × 100) = $318.
- Single unreviewed jump: 2 runs and 10 hours of untangling mixed failures. That is (2 × 30 × 0.10) + (10 × 100) = $1,006.
Under these assumptions, the extra CI runs cost about $12, and the reduced debugging time saves about $700.
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 upgrade, 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.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
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.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- 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.