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 — "
testneeds this package'sbuild, andbuildneeds 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.
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:
targetDefaultsapplies settings to every project's target of that name, so you configurebuildonce rather than in every package.dependsOn: ["^build"]means "build my dependencies first";["build"]ontestmeans "build myself first".inputsdecide what invalidates the cache;namedInputsgive reusable names to input sets. Getting inputs right is the subject of Configuring Nx Named Inputs for Accurate Caching.outputstell 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"
}
}
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:
- Caching only. Run
nx init, keep every workflow the same, and route root scripts throughnx run-many. Developers get cache hits; CI is unchanged. - Affected in CI. Add
fetch-depth: 0,nx-set-shasandnx affectedto the pull request pipeline, keeping a full run onmainas a safety net for the first few weeks. - Remote cache. Share results between CI runs and developers once you trust the inputs and outputs.
- 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.
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 explains Nx's project and task graphs in depth.
- Configuring Nx Named Inputs for Accurate Caching tunes what invalidates the cache.
- Configuring Nx Affected Commands in CI goes deeper on affected pipelines.
- pnpm Workspace Filtering covers what pnpm alone can already do.