What Lerna does in your repo, and why you should migrate the two jobs separately

In most repos, Lerna does two unrelated jobs. The first is task running: lerna run and lerna exec execute scripts such as build, test and lint across every package, in an order derived from the dependencies declared in each package.json. The second is release management: lerna version bumps versions, writes changelogs, commits and creates git tags, and lerna publish pushes packages to the npm registry. These jobs share a config file, but they have very different consequences when something goes wrong.

Task running is usually the reason teams look at Nx. Nx builds a project graph of your workspace and uses it for three things:

  • Ordering builds. It runs builds in the right order across packages.
  • Caching outputs. It caches task outputs, so unchanged packages are not rebuilt.
  • Affected runs. It runs only the projects affected by a change. On a CI pipeline that currently rebuilds and retests every package on every pull request, these features cut wall-clock time and compute cost directly.

Swapping the task runner is also the lowest-risk part of a lerna migration. A task runner reads source files and writes build artifacts. It never touches git tags, version fields or the registry. If it misbehaves, you revert a config change and rerun CI, and no consumer sees a broken release.

Release management is the opposite. A bad version bump or a partial publish is visible to everyone downstream and is hard to undo.

The rest of this guide therefore splits the work into two phases:

  1. Move task running to Nx while lerna version and lerna publish keep working exactly as before.
  2. Migrate versioning later, as a separate, deliberate change.

This approach fits backend and platform teams with multi-package repos, slow CI and a release flow that cannot break.

        Lerna today: one config, two unrelated jobs
      +-------------------------------------------------------+
      |        Lerna  (lerna.json, one shared config)         |
      +------+-----------------------------------------+------+
             |                                         |
             v                                         v
+--------------------------+              +--------------------------+
| TASK RUNNING             |              | RELEASE MANAGEMENT       |
| lerna run / lerna exec   |              | lerna version / publish  |
| build, test, lint        |              | bumps, changelogs, tags  |
| touches: source, outputs |              | touches: git tags, npm   |
| risk: LOW, revert config |              | risk: HIGH, consumers    |
+------------+-------------+              +------------+-------------+
             |                                         |
             | PHASE 1: now                            | PHASE 2: later
             v                                         v
+--------------------------+              +--------------------------+
| Nx (project graph)       |              | Migrate versioning       |
| - ordered builds         |              | as a separate change     |
| - cached outputs         |              | until then: lerna        |
| - affected runs          |              | version/publish as-is    |
+------------+-------------+              +--------------------------+
             |
             v
     faster, cheaper CI (cached, affected-only runs)

Lerna's two jobs carry different risk, so task running moves to Nx first while lerna version and lerna publish stay unchanged until a separate, later versioning migration.

Inventory current behavior before changing anything

Before touching Nx, write down what Lerna does today. This record is the acceptance test for the migration. When phase one is done, every item on it should behave identically. If something changed, you have a regression, even if CI is green.

Start with lerna.json. A typical file sets version to either a single number like 1.4.0 (fixed mode, where all packages share one version) or independent (each package versions on its own). It also lists packages globs such as packages/* and services/*, and sets command.version options like conventionalCommits, changelogPreset and a custom commit message. Each of these settings affects what gets released and how. Fixed mode means a single tag per release. Independent mode means one tag per changed package.

Capture these items:

  • Mode: fixed or independent, plus the current version if fixed.
  • Package locations: the packages globs, and whether workspaces in the root package.json list the same paths.
  • Versioning options: conventional commits, changelog preset, tagVersionPrefix, allowed branches, and any flags passed on the command line rather than set in config.
  • Every lerna run call: search CI workflows, root package.json scripts and shell scripts. Record the script name and flags such as --scope, --ignore, --since, --stream and --concurrency.
  • Tag format: copy two or three recent tags exactly, for example v1.4.0 or @acme/api@2.1.3.
  • Release commits: the exact message, the author, and which files the commit touches.

Store this list in the repo. You will diff against it later.

#!/usr/bin/env bash
# Record current Lerna behavior as a baseline. After phase one of the Nx
# migration, regenerate these files and diff them against this snapshot.
set -euo pipefail

OUT=migration-baseline
mkdir -p "$OUT"

# 1. Mode (fixed version or "independent"), package globs and versioning
#    options all live in lerna.json. Keep an exact copy.
cp lerna.json "$OUT/lerna.json"

# Root workspaces should list the same paths as lerna.json "packages".
node -p "JSON.stringify(require('./package.json').workspaces, null, 2)" \
  > "$OUT/workspaces.json"

# 2. Every Lerna invocation in CI, package.json scripts and shell scripts,
#    including flags like --scope, --ignore, --since, --stream, --concurrency.
grep -rnE "lerna (run|exec|version|publish)" \
  --include='*.yml' --include='*.yaml' --include='*.json' --include='*.sh' \
  --exclude-dir=node_modules --exclude-dir=.git . \
  > "$OUT/lerna-invocations.txt" || true

# 3. Recent tags, verbatim (e.g. v1.4.0 or @acme/api@2.1.3).
git tag --sort=-creatordate | head -n 10 > "$OUT/recent-tags.txt"

# 4. Release commits: message, author and touched files.
#    Adjust the pattern to match the commit "message" option in lerna.json.
RELEASE_PATTERN='chore(release)'
git log --fixed-strings --grep="$RELEASE_PATTERN" -n 3 \
  --format='%H%n%an <%ae>%n%s' --name-only > "$OUT/release-commits.txt"

# 5. Packages Lerna sees today, with versions and locations.
npx lerna list --all --long > "$OUT/lerna-list.txt"

echo "Baseline written to $OUT/. Commit it; diff against it after phase one."

A script that snapshots lerna.json, workspace paths, every Lerna invocation, recent tag formats, release commit details and the package list into a committed baseline directory that serves as the migration's acceptance test.

Phase one: add Nx with nx init and map Lerna commands to nx run-many

Run npx nx init at the repository root. In a Lerna workspace it adds Nx as a dev dependency and creates nx.json. It reads the package list from your existing workspaces configuration, so it does not move or rename anything. Each package becomes an Nx project, and each script in its package.json becomes a target with the same name. The scripts stay as they are, which means npm run build inside a package still behaves exactly as before.

To confirm the result, run nx show projects and compare its output with lerna list. The two lists should match one for one. Then run nx show project <name> on one or two packages and check that every script you inventoried appears as a target.

Next, replace the Lerna commands in CI one at a time:

Lerna command Nx equivalent What to check
lerna run build nx run-many -t build The same set of packages builds
lerna run test --since main nx affected -t test --base=origin/main The base ref matches what --since used
lerna run build --scope @acme/api nx run @acme/api:build The project name matches the package name

The affected command carries the most risk. Nx compares the base and head commits to work out which projects changed. If the CI checkout is shallow, the base commit may be missing. If the base ref points at the wrong branch or a stale commit, the diff comes out too small. In both cases fewer projects run than should, and tests are skipped without any error.

To prevent this:

  • Fetch full history, or at least enough history to include the merge base.
  • Set --base explicitly instead of relying on the default.
  • Log the list of affected projects on every CI run, so reviewers can see what actually ran.
#!/usr/bin/env bash
# CI step: run tests only for affected projects, with guards against
# shallow clones and a wrong base ref silently shrinking the diff.
set -euo pipefail

BASE_BRANCH="main"

# 1. Make sure history is deep enough to contain the merge base.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
  git fetch --unshallow origin
fi
git fetch origin "${BASE_BRANCH}"

# 2. Resolve the base explicitly instead of relying on Nx defaults.
#    The merge base is the commit this branch diverged from.
BASE_SHA="$(git merge-base "origin/${BASE_BRANCH}" HEAD)"
HEAD_SHA="$(git rev-parse HEAD)"
echo "Affected range: ${BASE_SHA}..${HEAD_SHA}"

# 3. Log what Nx considers affected so reviewers can see it in the CI output.
echo "Affected projects:"
npx nx show projects --affected --base="${BASE_SHA}" --head="${HEAD_SHA}"

# 4. Run the target against the same explicit range.
#    Replaces: lerna run test --since main
npx nx affected -t test --base="${BASE_SHA}" --head="${HEAD_SHA}"

A CI script that unshallows the clone, pins the base to the merge base with main, logs the affected projects, and then runs nx affected against that same explicit range.

How it works: task ordering, parallelism and caching in nx.json

Lerna ran scripts in topological order by default. Nx does not unless you tell it to. In nx.json, add a targetDefaults entry for build with dependsOn: ["^build"]. The caret means "run this target in every dependency first." Without it, a package can build against a stale or missing dist from a sibling. Lerna hid that problem, and Nx will expose it on the first clean CI run.

Nx also runs independent tasks in parallel, controlled by --parallel. Parallel runs break scripts that assumed they ran alone. Common examples are two test suites binding the same port, builds sharing a fixed /tmp path, and codegen writing into a common directory. Start with --parallel=1 to confirm parity with Lerna, then raise it and fix each collision you find.

Caching only helps if you configure it deliberately:

  • Declare outputs for build targets, such as {projectRoot}/dist. Nx restores exactly these paths on a cache hit, so any file you leave undeclared will be missing after a restore.
  • Set cache: false on publish, deploy, migrations and any other target with side effects. A cache hit on these would skip work that actually needs to run.
  • Use namedInputs to define what invalidates the cache. Exclude test files from production builds, and add { "env": "API_URL" }-style entries so that env-sensitive config changes the hash.

The annotated example below combines all three.

Keep remote caching out of this phase. A shared cache means you trust artifacts that other machines produced. If any writer is compromised or misconfigured, it can poison builds for everyone. Set it up later, once you have decided who has read and write access.

// nx.json (Nx parses this file as JSONC, so comments are allowed)
{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  // Start serial to confirm parity with Lerna, then raise and fix collisions
  "parallel": 1,
  "namedInputs": {
    // Everything in the project, plus workspace-wide inputs
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    // Changing the base tsconfig or API_URL changes every hash
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json", { "env": "API_URL" }],
    // Production builds ignore test files, so editing a spec does not rebuild
    "production": ["default", "!{projectRoot}/**/*.spec.ts", "!{projectRoot}/jest.config.*"]
  },
  "targetDefaults": {
    "build": {
      // Build every dependency first, matching Lerna's topological order
      "dependsOn": ["^build"],
      // Hash this project's production files and its dependencies' production files
      "inputs": ["production", "^production"],
      // Nx restores only these paths on a cache hit
      "outputs": ["{projectRoot}/dist"],
      "cache": true
    },
    "test": {
      "inputs": ["default", "^production"],
      "cache": true
    },
    // Side-effect targets must always run
    "publish": { "dependsOn": ["build"], "cache": false },
    "deploy": { "dependsOn": ["build"], "cache": false }
  }
  // No remote cache configured yet: local cache only until read and write access is decided
}

An annotated nx.json that restores Lerna-style topological builds with dependsOn ["^build"], starts with serial execution, declares build outputs, keeps publish and deploy uncached, and uses namedInputs so that test files are excluded from production builds and API_URL changes invalidate the cache.

Keep lerna version and lerna publish unchanged, and plan the rollback

Phase one changes how tasks run. It should not change what gets released. lerna version and lerna publish encode decisions about which packages changed, how versions are bumped, how tags are named, and what goes into changelogs. If you move those at the same time as task orchestration, a bad release could come from either change, and you won't know which one to roll back. Leave the release commands and their configuration in lerna.json untouched until the task-running side is stable.

Still, check that adding Nx hasn't affected the release path, because the two share the dependency graph and the root package.json. Cut a release from a throwaway branch. Run lerna version with --no-push so nothing reaches the remote, and point lerna publish at a private or local test registry instead of the real one. Then compare the result with a release made before the migration. Look at which packages got bumped, the tag names and format, and the changelog entries. If any of these differ, fix that before going further.

Developers shouldn't need to learn new commands yet. Keep the root scripts, such as build, test and lint, as the stable entry points, and change only what they run internally: they used to call lerna run and now call nx run-many. Remove any remaining direct lerna run calls in docs and pipelines only after CI has been green for a few weeks.

Rollback means reverting the CI configuration and root-script changes. Nothing else depends on Nx yet, so that revert is enough. Don't let both tools run side by side indefinitely. Set an end date for the overlap, and assign an owner to remove it when that date arrives.

Phase two: moving versioning to Nx release as a separate step

Once that overlap has ended, versioning is the only part of the migration left. It is also where mistakes get expensive. A wrong build can be reverted. A version published to a registry generally cannot be republished or reused, and consumers may already have installed it.

Start by translating the Lerna settings you inventoried earlier. Lerna's fixed mode, where all packages share one version, corresponds to a fixed project relationship in Nx release. Independent mode corresponds to an independent relationship. The tag format matters just as much. If your tags look like v1.4.0 or pkg-name@1.4.0, the Nx release tag pattern must produce exactly the same strings. Otherwise Nx won't find the last release, and it will compute versions from the wrong baseline. Option names and defaults in this area have changed over time, so check them against the documentation for the Nx version you have installed.

Before you switch, run both tools in a throwaway clone with pushing and publishing disabled. Nx release supports a dry-run mode. For Lerna, use options that skip pushing and publishing. Diff the planned versions, tags and changelog entries from each run. Any mismatch is a configuration bug you can fix now, before it turns into a published artifact.

Choosing a release path

Option When it fits Main cost
Stay on Lerna for releases Releases work and nobody owns a migration Two tools to maintain
Move to Nx release You want a single tool and config Careful config parity and a dry-run diff
Changeset- or commit-based tool You want a different release workflow New contributor habits and CI changes

When to avoid it, and the impact on cost and engineering time

Not every repo gains from the switch. If you have a handful of packages, builds that finish in under a minute, and a CI bill nobody discusses, staying on Lerna for task running is a valid outcome of this evaluation. Caching only pays when there is expensive work to skip.

The ongoing costs are real:

  • Auditing cache inputs. A missing environment variable or config file in inputs produces stale, wrong cache hits. Those are worse than slow builds.
  • Fixing race conditions. Parallel runs expose undeclared dependencies that Lerna's ordering happened to hide.
  • Keeping Nx up to date. Upgrades and their migrations need regular, scheduled time.
  • Debugging graph inference. When affected skips a project it should have run, someone has to trace why.

Illustrative estimate (assumed numbers, not a benchmark)

Net hours saved per month = (CI runs × minutes saved per run ÷ 60) − maintenance hours.

Assume 400 runs a month, 6 minutes saved per run through cache hits and affected runs, and 8 hours of maintenance:

(400 × 6 ÷ 60) − 8 = 40 − 8 = 32 hours a month.

With 2 minutes saved per run, the result is 13.3 − 8 ≈ 5 hours. That small margin can disappear after one cache-poisoning incident.

Signal Adopt now Defer
Package count Dozens and growing A few, stable
CI duration Many minutes per run Already fast
Cross-package changes Frequent, partial Rare, or always touch everything
Team capacity An owner for cache and graph config No one to own it
Release process Stable, can stay on Lerna for now Mid-change or fragile

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