Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Adding Nx to an Existing pnpm Workspace

You do not need to restructure a repository to use Nx. In "package-based" mode, Nx reads an existing pnpm workspace as it is — the package.json files, their scripts and their dependencies — and adds a task graph, local and remote caching, affected detection and a project graph viewer on top. Adoption can be one command and one small configuration file, and it can be reversed just as easily. This guide walks through nx init on a pnpm workspace, explains what Nx infers and what you configure, and sets up CI with affected runs.

What package-based Nx adds

A plain pnpm workspace already knows how packages depend on each other and can run scripts in topological order, as described in Running Workspace Scripts in Topological Order. Nx adds the parts that make large repositories fast:

  • Task-level dependencies — "test needs this package's build, and build needs dependencies' build" — rather than package-level ordering only.
  • Computation caching — a task whose inputs have not changed replays its outputs and logs instead of running.
  • Affected detection — run tasks only for projects changed since a base commit, plus everything that depends on them.
  • A project graph you can visualise and query.

The architecture behind these features is covered in Nx Workspace Architecture.

What Nx layers on top of a pnpm workspace The pnpm workspace provides packages and dependencies; Nx infers projects, builds a project graph, runs a task graph with caching and computes affected sets. 1 pnpm workspace pnpm-workspace.yaml, package.json files, workspace: dependencies 2 project inference every package.json becomes an Nx project; scripts become targets 3 project graph edges from declared dependencies (and source analysis) 4 task graph + cache targetDefaults, inputs, outputs, local and remote cache 5 affected only projects changed since a base, plus dependents
Nx reuses the workspace's own manifests; nothing about the package layout changes.

Initialising Nx

From the repository root:

pnpm dlx nx@latest init

The interactive prompt asks which scripts are cacheable, which scripts must run in dependency order, and whether to enable remote caching. It then installs nx as a root dev dependency, creates nx.json, and adds .nx/cache and .nx/workspace-data to .gitignore. Nothing inside the packages changes: each package.json script is now also an Nx target.

A typical resulting nx.json, lightly edited:

{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/*.test.ts",
      "!{projectRoot}/**/*.stories.tsx",
      "!{projectRoot}/vitest.config.ts"
    ],
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "outputs": ["{projectRoot}/dist"],
      "cache": true
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["default", "^production"],
      "cache": true
    },
    "lint": {
      "inputs": ["default", "{workspaceRoot}/eslint.config.js"],
      "cache": true
    }
  },
  "defaultBase": "main"
}

What each part does:

  • targetDefaults applies settings to every project's target of that name, so you configure build once rather than in every package.
  • dependsOn: ["^build"] means "build my dependencies first"; ["build"] on test means "build myself first".
  • inputs decide what invalidates the cache; namedInputs give reusable names to input sets. Getting inputs right is the subject of Configuring Nx Named Inputs for Accurate Caching.
  • outputs tell Nx which files to store and restore on a cache hit.

Running tasks

Scripts keep working with pnpm, and the same scripts can be run through Nx:

# One project's target
pnpm nx run @acme/web:build

# A target across all projects, ordered and cached
pnpm nx run-many -t build

# Several targets, in parallel where the graph allows
pnpm nx run-many -t lint test build --parallel=4

# Only projects affected since main
pnpm nx affected -t test

# See the graph in a browser
pnpm nx graph

Root scripts can delegate to Nx so developers keep their muscle memory:

{
  "scripts": {
    "build": "nx run-many -t build",
    "test": "nx affected -t test",
    "lint": "nx run-many -t lint"
  }
}
pnpm scripts alone versus pnpm plus Nx Compares a plain pnpm workspace with pnpm plus package-based Nx on ordering, caching, affected detection, graph visualisation and configuration. pnpm -r / --filter pnpm + Nx (package-based) Topological order yes yes, per task Local caching no yes Remote caching no Nx Cloud or self-hosted Affected detection changed files via git ref files + lockfile + graph Extra configuration none nx.json
Nx adds caching and affected runs without changing how packages are declared.

How Nx builds the project graph here

In package-based mode, Nx treats every folder with a package.json inside the workspace globs as a project, named after the package. Edges come from dependencies, devDependencies and peerDependencies that point at other workspace packages — the workspace: references. Nx can also analyse source imports to add edges, which helps catch imports you forgot to declare, though declared dependencies should remain the source of truth, as argued in Detecting Undeclared Cross-Package Imports.

Projects can carry extra Nx configuration in their package.json under an nx key, for example tags used by module boundary rules or target-specific overrides:

{
  "name": "@acme/ui",
  "nx": {
    "tags": ["scope:shared", "type:ui"],
    "targets": {
      "build": { "outputs": ["{projectRoot}/dist", "{projectRoot}/styles"] }
    }
  }
}

Inferred targets from plugins

Beyond package.json scripts, Nx plugins can infer targets from the tool configuration files already in your packages. With @nx/vite installed and registered in nx.json, any package containing a vite.config.ts gets build, serve, preview and test targets with correct inputs and outputs derived from the config — no script needed. @nx/eslint, @nx/jest, @nx/playwright and @nx/js (for tsc builds) work the same way.

{
  "plugins": [
    { "plugin": "@nx/vite/plugin", "options": { "buildTargetName": "build", "testTargetName": "test" } },
    { "plugin": "@nx/eslint/plugin", "options": { "targetName": "lint" } }
  ]
}

Inference is optional in package-based mode, and it is worth adopting gradually. The advantage is accuracy: a plugin reads build.outDir from the Vite config and declares it as the target's output, so the cache restores the right folder even when a package uses a non-standard output path. The cost is an extra layer to understand when debugging. Run pnpm nx show project @acme/web --web to see every target, where it came from (script or plugin) and its resolved inputs and outputs.

Adopting Nx incrementally

Teams worried about lock-in or disruption can introduce Nx in stages, each of which is useful on its own and reversible:

  1. Caching only. Run nx init, keep every workflow the same, and route root scripts through nx run-many. Developers get cache hits; CI is unchanged.
  2. Affected in CI. Add fetch-depth: 0, nx-set-shas and nx affected to the pull request pipeline, keeping a full run on main as a safety net for the first few weeks.
  3. Remote cache. Share results between CI runs and developers once you trust the inputs and outputs.
  4. Boundaries and plugins. Add module boundary rules and inferred targets once the team is comfortable with the project graph.

At each stage, compare cache hit rates and CI times with the previous stage. If a stage does not pay for itself, stop there — package-based Nx does not require any later step.

CI/CD integration

name: ci
on: [pull_request]
jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }              # affected needs history to find the base
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version-file: .nvmrc, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - uses: nrwl/nx-set-shas@v4              # sets NX_BASE / NX_HEAD from the last successful main run
      - run: pnpm nx affected -t lint test build --parallel=3

The nx-set-shas action picks the last commit on main for which CI succeeded as the base, which keeps affected sets accurate after failed runs. If affected unexpectedly selects every project, see Fixing Nx 'Affected' Detecting All Projects as Changed. Add remote caching so CI runs share results with each other and with developers, as covered in Configuring Nx Remote Caching.

Worked example: a 40-package repository in one afternoon

A team with 40 packages and a 25-minute CI pipeline runs nx init, accepts the defaults for build, test and lint, and edits nx.json to exclude test files and stories from the production inputs. Locally, a second pnpm nx run-many -t build finishes in seconds from cache. In CI, nx affected cuts typical pull request runs to the handful of packages actually touched, and median pipeline time drops from 25 to 7 minutes. Nothing in any package changed, and the team can remove Nx by deleting nx.json and one dev dependency if it ever needs to.

CI pipeline time before and after adding Nx Example median pipeline durations for pnpm recursive scripts, Nx affected without remote cache, and Nx affected with a remote cache. pnpm -r run build test 25 min nx affected, local cache only 7 min nx affected + remote cache 3 min
Affected runs deliver most of the gain; a shared remote cache removes repeated work across pipelines.

Pitfalls

Mistake Impact Remediation
Missing outputs on build Cache hits restore nothing; dependents fail Set outputs in targetDefaults
Inputs include test files for build Editing a test invalidates builds Use a production named input
Shallow clone in CI affected cannot find the base; runs everything fetch-depth: 0 and nx-set-shas
Caching non-deterministic tasks Stale or wrong replayed results Set cache: false for deploy, e2e with live services

Frequently Asked Questions

Do I need to move packages into apps/ and libs/ folders? No. Package-based Nx works with any layout your pnpm workspace globs describe.

Can Nx and Turborepo coexist? Technically, but running two task runners duplicates configuration and caching. Choose one, as discussed in Choosing a Monorepo Task Runner.

Will pnpm scripts still work without Nx? Yes. Nx wraps them; pnpm --filter @acme/web run build still runs the script directly, just without caching or dependency ordering.

Does Nx need a daemon process? Nx starts a background daemon locally to keep the project graph warm between commands, which makes repeated commands faster. It is disabled in CI by default. If it misbehaves, pnpm nx reset stops it and clears caches, and NX_DAEMON=false disables it for a single run.

How big does the local cache get? It grows with every cached task's outputs. Nx prunes old entries automatically based on size and age; pnpm nx reset clears it completely if you need disk space back.

Related

Nx Workspace Architecture