What nx migrate does and how its two phases work

nx migrate is the Nx command for moving a workspace from one Nx version to another. A plain version bump changes the packages, but it leaves your code and config behind. Nx and its plugins evolve their configuration formats, executor options, and generator defaults between releases. Each plugin ships migrations: small scripts that rewrite your workspace files to match the new expectations. nx migrate finds which of those scripts apply to your version jump and runs them in order.

The command splits the work into two phases so you can inspect the plan before anything changes.

Phase one resolves the target version. It updates the Nx-related entries in package.json and writes a migrations.json file listing every migration that applies. It does not modify source code or project configuration. You get a diff of version changes plus a list of pending changes to review.

Phase two starts after you install dependencies, because the migration scripts live inside the newly installed packages. Running nx migrate --run-migrations executes the entries in migrations.json against your workspace. These can rewrite nx.json, project configuration, and source files.

clean branch
     |
     v
nx migrate <target>  --> package.json (versions bumped)
     |                   migrations.json (planned changes)
     v
install dependencies
     |
     v
nx migrate --run-migrations  --> code + config rewritten
     |
     v
verify (build, lint, test)
     |
     v
merge

Flags and defaults have changed across Nx releases. Check the documentation for the Nx version installed in your workspace before relying on a specific option.

# Phase 0: start from a clean branch so every change is reviewable
git status --porcelain   # should print nothing
git switch -c chore/nx-upgrade

# Phase 1: plan only. Bumps Nx-related versions in package.json
# and writes migrations.json. No source or project config is touched.
npx nx migrate latest    # or a specific version, e.g. npx nx migrate <version>

# Review the plan before going further
git diff package.json
cat migrations.json
git add package.json migrations.json
git commit -m "chore: bump Nx versions and record migrations"

# Phase 2a: install, because the migration scripts ship inside
# the newly installed packages
npm install

# Phase 2b: apply the planned migrations to code and config
npx nx migrate --run-migrations
git add -A
git commit -m "chore: apply Nx migrations"

# Verify before merging
npx nx run-many -t build lint test

# Once verified, migrations.json is no longer needed
git rm migrations.json
git commit -m "chore: remove applied migrations.json"

A shell walkthrough of the two-phase nx migrate workflow, committing after each phase so the version bump and the code rewrites can be reviewed separately before merging.

Who should own the upgrade and when to run it

The engineers who get the most out of a disciplined nx migrate routine are the ones maintaining the monorepo itself: platform teams who own CI, shared build configuration and generators, and backend engineers whose services depend on Nx plugins for build, test and lint targets. Application teams feel the upgrade too, but they usually see it as a diff landing in their projects, not as work they start.

One owner per upgrade

Assign each workspace update to one person, start to finish. Migrations rewrite configuration across many projects at once. If two people generate migrations.json on separate branches, or one person applies half the migrations and someone else finishes them, you end up with conflicting rewrites that are hard to untangle. A single owner also keeps the context in one head: which migrations ran, which ones failed, and which manual fixes followed. Rotate the role between upgrades so the knowledge spreads, but don't split a single upgrade.

When to run it

Put upgrades on a regular schedule, for example once per quarter, so each one stays small. Don't let the workspace fall more than one major version behind. The further back you are, the more migrations you have to apply and the harder it gets to tell which one broke something. Start only when CI on the main branch is green. A clean baseline means any new failure came from the upgrade.

When to hold off

Wait if a third-party plugin you depend on doesn't yet support the target version. Wait during a release freeze. Wait if CI is already red. Fix the existing failures first, because otherwise they hide the ones the upgrade introduces.

Preparing the branch and reading migrations.json

Start from a clean working tree. nx migrate rewrites package.json and later edits files across the repo, so any uncommitted change will be mixed into the upgrade diff and impossible to separate during review. Create a dedicated branch off the latest main, confirm CI passes on that commit, and run nx report. Paste its output into the pull request description. It lists the installed Nx core and plugin versions, which gives you a baseline for comparison when something breaks later.

Run the first phase with an explicit target, such as nx migrate followed by the version you chose in the previous step, instead of latest. A pinned target keeps the run reproducible: if a teammate repeats it next week, they get the same result. This phase updates package.json and writes migrations.json without touching any source files.

Each entry in migrations.json has four fields worth reading:

  • package: the plugin that owns the migration.
  • name: a stable identifier you can search for in the plugin's changelog.
  • version: the release that introduced the migration.
  • description: a short summary of what the migration changes.

Scan the descriptions and sort each entry into one of three groups. Some edit workspace config (nx.json, target defaults), some rewrite project.json files, and some modify source or test code. The source-code group carries the most risk and needs the most careful review.

Commit package.json and migrations.json before running anything. Reviewers can then approve the plan, or remove entries from it, before any code changes.

#!/usr/bin/env bash
# Phase 1 of an Nx upgrade: plan only, no source edits.
# Usage: ./plan-nx-upgrade.sh <nx-version>
set -euo pipefail
TARGET="${1:?usage: $0 <nx-version>}"

# Refuse to start on a dirty tree so the upgrade diff stays isolated.
if [ -n "$(git status --porcelain)" ]; then
  echo "Working tree is not clean; commit or stash first." >&2
  exit 1
fi

# Branch from the latest main. Confirm CI is green on this commit before going on.
git fetch origin
git switch -c "chore/nx-migrate-${TARGET}" origin/main

# Record the baseline Nx core and plugin versions for the PR description.
npx nx report > nx-report-before.txt

# Phase 1: pin the target version. This updates package.json and writes migrations.json.
npx nx migrate "$TARGET"

# List every entry as package, name, version, and description.
jq -r '.migrations[] | [.package, .name, .version, .description] | @tsv' migrations.json

# Rough triage by keyword. Read every description; do not rely on this alone.
echo "--- likely workspace config (nx.json, target defaults) ---"
jq -r '.migrations[] | select(.description | test("nx.json|targetDefaults|target defaults"; "i")) | .name' migrations.json
echo "--- likely project.json rewrites ---"
jq -r '.migrations[] | select(.description | test("project.json|project configuration"; "i")) | .name' migrations.json

# Commit the plan so reviewers can approve it or remove entries before phase 2.
git add package.json migrations.json
git commit -m "chore: plan nx migration to ${TARGET}"

This script checks for a clean tree, creates the upgrade branch, records the nx report baseline, runs the first phase of nx migrate against a pinned version, lists and roughly triages the migrations.json entries with jq, and commits the plan for review.

Stepping through major versions vs jumping to latest

Before generating that plan, decide what target to pass to nx migrate. You can name the next major after your current one, or you can pass latest and let Nx collect every migration between where you are and the newest release. The choice changes how much reviewers have to read and how hard it is to find the cause when something breaks.

Factor One major at a time Direct jump to latest
Migration size Small migrations.json, one release's worth of changes Every migration across all skipped majors in one file
Attributing breakages Narrow: the failure came from this major Wide: any skipped release could be the cause
Number of PRs One per major One, usually large
CI cycles Several full runs One run, but failures take longer to bisect
Testing burden Spread out and incremental Concentrated, needs deeper manual and integration testing

Stepping through majors costs more total wall-clock time, but each step is easy to review. When a build fails after a single-major migration, you only have one changelog to check. After a multi-major jump, a single failure could come from a renamed executor, a changed default, or a plugin that was dropped two releases back.

For each major, read the official changelog or release notes before you run the migrations. Migrations handle mechanical rewrites. The release notes also cover behavioural changes that no codemod can apply for you.

A direct jump is still a reasonable choice for teams that are several majors behind and cannot afford a long series of PRs. Treat it as a deliberate trade: plan for more testing, a longer review, and a team that expects to spend time on bisection.

Applying migrations in reviewable commits and verifying uncached

Split the upgrade into three commits so each one answers a single question for the reviewer.

  1. Version bump. Include package.json, the lockfile, and the generated migrations.json. This commit shows what changed upstream and which migrations Nx plans to run.
  2. Migration output. Run nx migrate --run-migrations and commit the result alone. Everything in this diff was written by a tool, so the reviewer can skim it for anything unexpected.
  3. Manual fixes. Put each hand-written change in its own commit, with a message explaining why it was needed. These commits carry the most risk and deserve the closest reading.

Before verifying anything, do a clean reinstall from the lockfile (npm ci or your package manager's equivalent). A node_modules left over from before the bump can hide resolution problems that CI will hit. Then run nx run-many -t lint test build --skip-nx-cache across all projects. The cache bypass is essential, because cached results were computed under the old toolchain and could report success for work that never ran under the new one. For services where the build artifact matters, compare the bundle contents, the generated configs, or the resolved TypeScript settings against output from the main branch.

A migration run that finishes without errors does not mean the upgrade is correct. Migrations only cover changes the maintainers chose to automate. A new default, a stricter type check, or a change in executor behavior may have no migration entry and will only show up in tests or at runtime.

Reviewers should spend their time on configuration and behavior changes, such as executor options, target defaults, and tsconfig or test-runner settings. Mechanical import rewrites do not need line-by-line review.

# Commit 1: version bump only (package.json, lockfile, migrations.json)
npx nx migrate latest            # rewrites package.json, writes migrations.json
npm install                      # refresh the lockfile to match the new versions
git add package.json package-lock.json migrations.json
git commit -m "chore: bump Nx packages and generate migrations.json"

# Commit 2: tool-generated migration output, nothing else
npx nx migrate --run-migrations
git add -A
git commit -m "chore: apply Nx migrations (generated)"

# Commit 3+: each hand-written fix in its own commit, explaining why
# git commit -m "fix(api): set explicit target default the old version assumed"

# Verify from a clean install so stale node_modules can't mask resolution issues
rm -rf node_modules
npm ci

# Bypass the cache: cached results were produced by the old toolchain
npx nx run-many -t lint test build --skip-nx-cache

# Where artifacts matter, compare against main built in a separate worktree
git worktree add ../ws-main main
(cd ../ws-main && npm ci && npx nx build api --skip-nx-cache)
diff -r ../ws-main/dist/apps/api dist/apps/api

# Compare resolved TypeScript settings to catch changed defaults or strictness
diff <(cd ../ws-main && npx tsc -p apps/api/tsconfig.app.json --showConfig) \
     <(npx tsc -p apps/api/tsconfig.app.json --showConfig)

git worktree remove ../ws-main

Splits an Nx upgrade into a version-bump commit, a generated-migration commit, and separate manual-fix commits, then verifies with a clean lockfile install, an uncached lint/test/build run, and diffs of build output and resolved tsconfig against main.

Handling what migrations miss, cleaning up, and rolling back

Migrations only cover packages that ship them. Several common gaps remain after the automated pass.

Gaps to expect

Community plugins. A third-party plugin may lag behind the new Nx release. You have four options:

  • Wait for the plugin to catch up, and hold the upgrade.
  • Pin the plugin and accept peer warnings, if your tests pass.
  • Fork it, when the fix is small and you can maintain it.
  • Replace it with a core plugin or a plain run-commands target.

Pinning is the cheapest of these, but the debt grows with each release you skip.

Your own executors and generators. No migration will rewrite these. Recompile them and run each one against a real project. Type errors from changed devkit APIs usually show up here first.

Peer dependency conflicts. These often hide behind an install that otherwise succeeds. Read the package manager's warnings instead of forcing resolution, because a forced peer is a runtime bug you have deferred.

Documenting manual fixes

Record every manual fix in the PR description: what broke, why the migration missed it, and the commit that fixes it. That record is what the next person upgrading will need most.

Cleaning up migrations.json

Delete migrations.json in a follow-up commit once the upgrade is merged. If you keep it too long, someone may re-run stale migrations against code that has since changed. If you delete it before the branch is verified, you lose the list of what ran.

Rolling back

To roll back, git revert the merge commit so that package.json and the lockfile return together. Then reinstall strictly from that lockfile with npm ci or your package manager's frozen-lockfile mode. Finish with nx reset so that no cached outputs built by the new version are reused.

#!/usr/bin/env bash
# Post-upgrade housekeeping and rollback for an Nx workspace.
# Run from the repository root on an up-to-date main branch.
set -euo pipefail

# --- Cleanup: after the upgrade PR is merged and verified ---
# Remove migrations.json in its own commit so nobody re-runs
# stale migrations against code that has changed since.
cleanup() {
  git rm migrations.json
  git commit -m "chore: remove migrations.json after Nx upgrade"
}

# --- Rollback: undo the upgrade as a single unit ---
# MERGE_SHA is the merge commit of the upgrade PR (placeholder value).
rollback() {
  local MERGE_SHA="${1:?usage: $0 rollback <merge-sha>}"

  # -m 1 keeps the mainline parent, so package.json and the
  # lockfile return to their pre-upgrade state together.
  git revert -m 1 --no-edit "$MERGE_SHA"

  # Install strictly from the restored lockfile; fail rather than
  # silently re-resolving versions. (yarn: --immutable, pnpm: --frozen-lockfile)
  npm ci

  # Clear the Nx cache and daemon so outputs built by the newer
  # Nx version are not reused by the older one.
  npx nx reset

  # Confirm the old toolchain still builds and tests cleanly.
  npx nx run-many -t build test
}

case "${1:-}" in
  cleanup)  cleanup ;;
  rollback) rollback "${2:-}" ;;
  *) echo "usage: $0 {cleanup | rollback <merge-sha>}" >&2; exit 1 ;;
esac

A small script with two steps: one deletes migrations.json in a follow-up commit, and the other rolls back a merged Nx upgrade by reverting the merge commit, reinstalling from the restored lockfile, and resetting the Nx cache.

Alternatives, cost, and how routine upgrades save engineering time

Approach Who fixes breakage Main cost Main risk
nx migrate, stepped by major Migrations first, then you Uncached CI runs and PR review per step Edge cases the migrations miss
Manual version bumps You, entirely Reading changelogs and rewriting config by hand Missed renames that fail at runtime, not build time
Staying pinned Nobody, until forced Plugin and Node compatibility drift A large forced upgrade at a bad time
Direct jump to latest Migrations, but many at once One large, hard-to-review diff Overlapping failures with no clear cause

Illustrative estimate (assumed numbers, not a benchmark)

Routine cost per year = cycles × (uncached CI hours + review hours + fix hours) Deferred cost = engineers × days × hours per day

Assume four cycles a year. Each cycle has a 40-minute uncached CI run, 2 hours of review, and 1 hour of fixes. That gives 4 × (0.67 + 2 + 1) ≈ 15 engineer-hours a year. Now assume the team defers for two years and then needs 3 engineers for 3 days at 8 hours a day. That gives 72 hours, during which feature work stalls. Put in your own numbers. The deferred figure usually grows faster than linearly, because failures from several majors overlap and are harder to tell apart.

Three practices keep the routine path cheap:

  • Small, scheduled cycles. Each diff stays small enough that a failure points to a single migration.
  • A single owner. That person learns which plugins tend to break and keeps context between cycles.
  • A CI gate that requires a full uncached run. A stale cache can hide a broken executor until someone else's unrelated PR fails.

When to avoid it

nx migrate assumes your workspace looks roughly like what the Nx plugin authors expect. When that assumption breaks down, the tool can create more work than it saves.

The workspace barely uses Nx plugins

If Nx is only a task runner over package.json scripts, with few or no official plugins, the migrations have little to change. Warning sign: across several versions, migrations.json comes back empty or contains only Nx's own housekeeping entries. A plain dependency bump followed by your normal test run is simpler and easier to review.

You have patched or forked official plugins

Migrations are written against upstream defaults. If you carry patch-package patches against Nx packages, or your project.json files point executors at local forks, a migration can overwrite your customizations or fail halfway through. Warning sign: the diff touches files you remember editing by hand. Settle the fork question first: upstream the change or own the fork completely.

A freeze or incident is in progress

An upgrade changes the lockfile and shared config, so every project lands in the affected set. If production breaks during that window, you cannot tell whether the upgrade or the incident work caused it. Warning sign: nx affected lists the entire graph while a hotfix branch is open.

The workspace is about to be split or retired

Do not spend upgrade effort on a repo you plan to break apart next quarter. Upgrade the projects after they move into their new home.

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.
  • 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.