Three layers, not three competitors

A monorepo toolchain has three jobs, and these tools mostly do different ones. Package manager workspaces (Yarn, npm, pnpm) install dependencies, deduplicate them across packages, and symlink local packages so @acme/api can import @acme/db without publishing it first. Task runners such as Nx read the dependency graph, decide which tasks run and in what order, and skip work whose inputs haven't changed. Lerna versions packages, writes changelogs and publishes them to a registry.

These layers are easy to confuse because each tool reaches into its neighbours. pnpm can run scripts recursively in dependency order. Current Lerna hands task running to Nx. Nx has its own nx release command. Even so, each tool has one primary job, and that job is what you should pick it for.

Lerna: bump versions, changelogs, publish

| delegates task running to

Nx: project graph, task order, cache, affected

| reads packages and links created by

Yarn / npm / pnpm workspaces: install, dedupe, symlink

Responsibility Workspaces Nx Lerna
Install / link Yes No, uses the package manager No, uses the package manager
Build order Basic recursive runs Yes, from the task graph Via Nx
Caching No Local and remote Via Nx
Affected runs Limited change filters Yes --since, via Nx
Versioning No nx release Yes
Publishing One package at a time nx release Yes

Because Lerna's task running is Nx underneath, "nx vs lerna" is mostly a question of whether you publish packages. If you don't, Lerna adds little.

+-----------------------------------------------------------+
|  LERNA  (release layer)                                   |
|  bump versions -> write changelogs -> publish to registry |
+-----------------------------------------------------------+
                              |
                              | delegates task running to
                              v
+-----------------------------------------------------------+
|  NX  (task-running layer)                                 |
|  project graph -> task order -> cache -> affected runs    |
+-----------------------------------------------------------+
                              |
                              | reads packages and links created by
                              v
+-----------------------------------------------------------+
|  YARN / NPM / PNPM WORKSPACES  (install layer)            |
|  install deps -> dedupe -> symlink local packages         |
|  @acme/api imports @acme/db without publishing it         |
+-----------------------------------------------------------+

Each tool owns one layer: workspaces install and link packages, Nx plans, caches and skips tasks on top of them, and Lerna adds versioning and publishing while delegating task running to Nx.

What workspaces do, and where they stop

A workspace is a package-manager feature. The root package.json declares a workspaces field, for example "workspaces": ["packages/*", "services/*"], and npm, Yarn, or pnpm (which uses pnpm-workspace.yaml instead) then treats every matching directory as a member. One install resolves the whole tree against a single lockfile. Shared versions are deduped, and with npm and Yarn's default linker they are hoisted into the root node_modules. When services/api depends on @acme/logger and a workspace provides that package at a matching version, the manager symlinks the local directory instead of fetching from the registry. You edit the library and the service sees the change immediately.

The same tools can fan a script out across members: npm run build --workspaces --if-present, pnpm -r run build, or Yarn's workspaces foreach. pnpm and Yarn can order the run by declared package dependencies, but that ordering is the full extent of their graph awareness. They don't know that test needs the upstream build output, which files a task reads, or whether anything changed since the last run. Every invocation re-executes every script. With five packages that cost is negligible. With fifty, CI rebuilds code nobody touched on every push.

Hoisting has a second cost. Because dependencies land at the root, a package can require something it never declared and still work locally, since Node's resolution walks up to the root node_modules. That import fails when the package is installed alone or published. pnpm's isolated layout catches this by default. With hoisting linkers, you need a lint rule that rejects imports of undeclared dependencies.

# Root manifest: private, and lists which directories are workspace members.
# The build/test scripts fan out to every member that defines them.
cat > package.json <<'EOF'
{
  "name": "acme-monorepo",
  "private": true,
  "workspaces": ["packages/*", "services/*"],
  "scripts": {
    "build": "npm run build --workspaces --if-present",
    "test": "npm run test --workspaces --if-present"
  }
}
EOF

# One install for the whole tree, resolved against a single package-lock.json.
# Shared versions are deduped and hoisted into ./node_modules.
npm install

# @acme/logger is a symlink to packages/logger, not a registry download,
# so edits to the library are visible to services/api immediately.
ls -l node_modules/@acme/

# Runs every member's build script on every invocation.
# There is no cache, no change detection, and no record of which outputs
# a downstream task depends on.
npm run build

# Hoisting trap: services/api never declared lodash, but Node's resolution
# walks up to the root node_modules, where another member's lodash was hoisted.
# This succeeds here and fails when services/api is installed or published alone.
cd services/api
node -e "require('lodash'); console.log('resolved via hoisting')"

A minimal npm workspaces setup that installs once, links a local package, fans a build out across all members without caching, and shows how a hoisted, undeclared dependency resolves locally only by accident.

How a task runner works: graphs, ordering and caching

A task runner starts where the workspace stops. Nx reads each package's dependencies and import statements to build a project graph, which records which package depends on which. It then combines that graph with your target configuration to produce a task graph, where each node is a specific task such as api:test or shared:build.

Declaring the pipeline

The config example below sets three properties for each target:

  • dependsOn: "^build" means "build my dependencies first." This is how test waits for upstream build outputs. Tasks with no path between them in the graph run in parallel, up to a concurrency limit.
  • inputs: the files, environment variables and runtime values hashed into the cache key. If an input is missing from this list, a change to it will not invalidate the cache.
  • outputs: the directories the runner stores and restores when it gets a cache hit.

Local vs. remote cache

A local cache only speeds up repeat runs on one machine. A shared remote cache lets CI agents and developers reuse each other's results. The tradeoff is that every consumer then trusts every writer.

A cache hit is only safe when the task is deterministic. Its result must depend only on the declared inputs, environment, tool versions and outputs. The common failures are:

  • Undeclared environment variables. If API_URL is read at build time but not declared as an input, a staging build can be served in production.
  • Generated files. Code produced by an earlier untracked step can be silently excluded from the hash.
  • Write access. Anyone who can write to the remote cache can plant artifacts that every consumer will restore. Restrict writes to trusted CI and give developers read-only access.
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
    "production": ["default", "!{projectRoot}/**/*.spec.ts"]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": [
        "production",
        "^production",
        { "env": "API_URL" },
        { "runtime": "node --version" }
      ],
      "outputs": ["{projectRoot}/dist"],
      "cache": true
    },
    "test": {
      "dependsOn": ["^build"],
      "inputs": ["default", "^production"],
      "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
      "cache": true
    }
  }
}

An nx.json sketch in which build and test both wait for upstream builds ("^build"), the cache key covers source files, the API_URL environment variable and the Node version so a staging build is never served as production, and outputs lists the directories restored on a cache hit.

Affected runs in CI: implementing them without surprises

Affected execution is a diff followed by a graph walk. The tool computes the merge base between a base reference and a head reference, lists the files changed between them, and maps each file to the project that owns it. It then walks the project graph in reverse to collect every dependent of those projects and runs the requested targets only for that set. Changes to files that belong to no single project, such as the root lockfile or shared config, can mark many or all projects affected. A typical invocation is nx affected -t lint test build --base=origin/main --head=HEAD.

The answer is only as good as the base. On a pull request, the base should be the target branch, so the merge base matches what will land. On the main branch, HEAD~1 is a trap: if several commits arrive in one push, or a previous run failed, the changes in between are never tested. Use the last commit that passed CI instead.

Git history causes most surprises. A shallow clone may not contain the merge base, so the diff either fails or no longer reflects the branch. Fetch full history, or at least enough to reach the base. A base SHA recorded before a rebase may no longer be an ancestor of head, which inflates the diff into a near-full rerun.

Nx also offers module boundary rules, plugins that infer tasks from existing tool config, and distributed CI execution (1). Adopt these later, one at a time. Generated code and inferred targets are cheap to add, but they get expensive when they break and nobody on the team can say where a task's definition came from.

#!/usr/bin/env bash
# Run only affected lint/test/build targets in CI.
set -euo pipefail

TARGET_BRANCH="${TARGET_BRANCH:-main}"   # branch the PR merges into
IS_PULL_REQUEST="${IS_PULL_REQUEST:-false}" # set by your CI
LAST_GREEN_SHA="${LAST_GREEN_SHA:-}"        # last main commit that passed CI, stored by your pipeline

# A shallow clone may not contain the merge base, so fetch full history.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
  git fetch --unshallow origin
fi
git fetch origin "$TARGET_BRANCH"

if [ "$IS_PULL_REQUEST" = "true" ]; then
  # Compare against the target branch so the diff matches what will land.
  BASE="origin/$TARGET_BRANCH"
else
  # On main, use the last green commit, not HEAD~1, so commits from
  # multi-commit pushes or failed runs are not silently skipped.
  BASE="$LAST_GREEN_SHA"
fi

# A SHA recorded before a rebase or force-push may no longer be an ancestor
# of HEAD. Run everything explicitly rather than trust a distorted diff.
if [ -z "$BASE" ] || ! git merge-base --is-ancestor "$BASE" HEAD; then
  echo "Base '$BASE' is missing or not an ancestor of HEAD; running all projects"
  npx nx run-many -t lint test build
  exit 0
fi

echo "Running affected targets for $BASE...HEAD"
npx nx affected -t lint test build --base="$BASE" --head=HEAD

A CI script that fetches full git history, picks the base (target branch on PRs, last green commit on main), checks that the base is an ancestor of HEAD, and runs nx affected, or runs all projects explicitly when the base is unusable.

Where Lerna fits: versioning and publishing

Lerna's distinctive job today is the release step: deciding which packages get new versions, writing changelogs, tagging commits, and publishing to a registry. Task execution is no longer its concern. Modern Lerna hands lerna run to Nx, so the graph and caching described earlier apply. That leaves lerna version and lerna publish.

The main decision is the versioning mode. In fixed mode, every package shares one version number recorded in lerna.json, and a release moves all changed packages to that number. In independent mode, each package keeps its own version, and Lerna asks you to choose a bump per changed package. With conventional commits, Lerna can infer the bump from commit messages instead. In either mode, Lerna can generate changelogs from the same commit history.

Concern Fixed mode Independent mode
Simplicity One number to communicate and reason about Many numbers to track per release
Coordination overhead Low: one release decision Higher: a bump decision for each changed package
Unnecessary bumps Common: packages with no meaningful change still move Rare: only changed packages move
Risk of incompatible version combinations Low: matching numbers are tested together Higher: consumers can mix versions you never tested together

Fixed mode suits tightly coupled packages that consumers install together. Independent mode suits loosely related libraries with separate audiences.

Lerna is not the only option for this layer. Nx has its own release command. Changeset-style tools take a different approach: each contributor records the intended bump in a file alongside the pull request, instead of the tool inferring it from commits.

If your monorepo holds only apps and services that you deploy and never publish as packages, you can skip this layer entirely.

Who should use which combination, and when to avoid each

Repo profile Recommended layers Reasonable alternative
A handful of internal packages, nothing published, CI finishes quickly Workspaces only Plain package.json scripts run in a fixed order
Many interdependent apps and services, CI time growing with every project Workspaces + task runner (Nx) Hand-written CI matrix per service, if the dependency graph rarely changes
Libraries published to a registry Workspaces + task runner + Lerna or another release tool Manual version bumps and publish scripts for one or two packages
TypeScript alongside Go or Python Workspaces for the JS side + task runner with explicit task config Separate per-language pipelines

Start with workspaces alone and add a layer only when you can name the pain it removes. A task runner pays off when the cost of rebuilding and retesting unchanged projects exceeds the cost of maintaining its configuration. In a repo with five packages and a three-minute pipeline, that trade usually goes the other way. You take on a new CLI, cache directories to manage, CI changes to make, and another concept for every new hire to learn, and you save seconds.

Skip Lerna if nothing leaves the repo as a package. Its value is coordinated versioning, changelogs and publishing. Deployed services get their versions from your deploy pipeline, so Lerna adds nothing for them.

Mixed-language repos need the most care. Nx builds its graph from JavaScript imports and package.json dependencies. A Go service or Python package is invisible to that analysis unless a plugin covers it or you declare implicitDependencies and task inputs yourself. If those declarations are wrong, the cache returns stale results and affected runs skip projects that actually changed. Check that edits to non-JS files invalidate the tasks you expect before you trust the cache in CI.

Impact on CI time and engineering cost

Illustrative estimate (assumed numbers, not a benchmark). Assume a workspace of 40 projects. A change touches 3 of them, each changed project has 5 dependents, and every task takes 2 minutes.

  • Full run: 40 projects × 2 min = 80 task-minutes.
  • Affected run: at most (3 changed + 3 × 5 dependents) = 18 projects × 2 min = 36 task-minutes. The real figure is lower if the changed projects share dependents, because each project runs only once.
  • With caching: suppose half of those 18 tasks have inputs identical to an earlier run, such as a dependent rebuilt on another branch. They replay from cache, so the run drops to about 9 × 2 = 18 task-minutes.

The general formula is:

task-minutes ≈ (changed + unique dependents) × minutes per task × (1 − cache hit rate)

Task-minutes measure compute spend, not wall-clock time. Parallelism shortens the wall-clock time but leaves the total unchanged. Your actual savings depend on graph shape. In a project graph where most projects depend on one shared library, almost every change triggers a near-full run. Savings also depend on how much the dependents overlap and on your cache hit rate.

What it costs

  • Configuration upkeep. Input and output declarations have to track every new file type and generator.
  • Upgrade cadence. Each tool release can change config formats or plugin behavior.
  • Stale caches. These are harder to debug than slow builds. A slow build is obvious. A wrong cache hit produces a green pipeline for code that was never tested.
  • Remote cache access control. Anyone who can write to the remote cache can inject artifacts that other machines will trust.
  • Onboarding. New engineers need to learn why a task didn't run before they can trust the result.

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 vs lerna, 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.