Nx Monorepos in Practice: Affected Builds, Cache Inputs and Module Boundaries
Key takeaways
Nx speeds up a monorepo with two mechanisms: a project graph that tells it which projects a change affects, and a computation cache keyed on declared inputs. Both are only as correct as the information you give them, and most Nx problems in CI come from a wrong base commit or an undeclared cache input.
What Nx is actually doing
A monorepo with a plain npm run build --workspaces rebuilds everything, every time, in whatever order the package manager chooses. Nx replaces that with two ideas:
- A project graph. Nx reads your workspace (package.json workspaces,
project.jsonfiles, and the imports in your source code) and builds a graph of which project depends on which. That graph drives both task ordering (^buildmeans “build my dependencies first”) andaffecteddetection. - A computation cache. Before running a task, Nx computes a hash of the task’s inputs: source files, dependency hashes, configuration, the command itself. If it has seen that hash before, it replays the stored terminal output and restores the declared output files instead of running the task.
Both are fast when they are correct and dangerous when they are not. A graph that misses a dependency skips a project that should have been tested. A hash that misses an input replays a stale artifact. Most of this article is about keeping those two things correct.
Getting started
# New workspace
npx create-nx-workspace@latest my-workspace
# Add Nx to an existing npm/pnpm/yarn workspace
npx nx@latest init
nx init on an existing repo is the lower-risk route: it detects your package.json scripts and tools, adds nx.json, and lets you run existing scripts through Nx (npx nx build my-package) to get caching without restructuring anything.
The commands you will use daily:
nx build web # run one target on one project
nx run-many -t build test # a target across all projects
nx affected -t lint test build # only projects affected by the current change
nx graph # open the interactive project graph
nx show projects --affected # list affected projects without running anything
nx reset # clear the local cache and stop the daemon
In recent Nx versions many targets are inferred by plugins rather than written by hand: with @nx/vite/plugin registered in nx.json, a project that has a vite.config.ts gets build, serve and test targets automatically. Run nx show project web to see the fully resolved configuration, including inputs and outputs, for a project. When a cache behaves strangely, this is the first thing I check, because the configuration I think I have and the one Nx resolved are not always the same.
Task pipelines and outputs
Task dependencies are usually defined once in nx.json and apply to every project:
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"outputs": ["{projectRoot}/dist"],
"cache": true
},
"test": {
"inputs": ["default", "^production"],
"cache": true
},
"lint": {
"cache": true
}
}
}
"dependsOn": ["^build"]: the caret means “thebuildtarget of my dependencies”. Without the caret it means another target on the same project.outputstells Nx which files to store and restore. If this is wrong, you get the most confusing cache bug there is: Nx reports the task as a cache hit, prints the old build log, and thedistfolder the next step needs is empty or missing, because the files were written somewhere Nx was not told to capture.testdoes not need to depend onbuildunless your tests actually consume build output. Adding"dependsOn": ["build"]to every test target is a common way to make CI slower for no benefit.
Affected commands and the base commit
nx affected compares two commits, base and head, collects the changed files, maps them to projects, and then walks the graph to include every project that depends on a changed one. Change libs/ui and apps/web (which imports it) is affected too; apps/api (which does not) is not.
The correctness of this depends entirely on base. Locally, the default base is main, which works. In CI it is where things break.
The shallow clone problem
actions/checkout fetches a single commit by default. Nx then has no history to compare against: git cannot find origin/main or a merge base, and the step fails with a git error along the lines of fatal: Not a valid object name, or in some setups everything is treated as affected. The fix is to fetch history:
- uses: actions/checkout@v4
with:
fetch-depth: 0
Pick the right base
--base=origin/main is fine for pull requests: it means “what changed on this branch compared to main”. On a push to main itself it is wrong, because HEAD and origin/main are the same commit and nothing is affected. What you actually want on main is “what changed since the last commit where CI passed”. The nrwl/nx-set-shas action computes exactly that and exports it as NX_BASE and NX_HEAD, which nx affected reads automatically:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- uses: nrwl/nx-set-shas@v4
- run: npx nx affected -t lint test build
The reason “last successful run” matters: if commit A breaks a test on main and commit B fixes something unrelated, comparing B to A would skip the still-broken project. Comparing to the last green commit keeps it in scope until it passes.
I think of affected as a performance optimization with a correctness cost. It trusts the graph completely, so anything the graph does not know about is invisible to it. A shell script that reads a JSON file from another package, a test that loads fixtures via a path string, a Docker build that copies a sibling folder: none of those are imports, so none of them create edges. When I have seen affected skip a project that then broke on the next full build, it has almost always been one of these implicit dependencies. You can declare them with "implicitDependencies": ["other-project"] in project.json, and I also keep a scheduled full nx run-many -t test as a safety net.
Cache correctness: declare your inputs
Nx’s default inputs for a project are roughly “all files in the project folder plus shared global files”, configured through namedInputs:
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/**/*.test.ts"
]
}
}
The production set excludes test files, so editing a spec file does not invalidate the build cache of every downstream app. That is the main win from named inputs.
What the default does not include is anything outside the project folder that you have not listed, and environment variables. Both are classic sources of wrong cache hits.
Environment variables
Consider a frontend build that inlines API_URL at build time. Staging and production builds use the same source files, so by default they produce the same hash. Whichever runs second gets a cache hit and restores the bundle built with the other environment’s URL. Nothing fails; the wrong artifact just ships.
The fix is to make the variable part of the hash:
{
"targetDefaults": {
"build": {
"inputs": [
"production",
"^production",
{ "env": "API_URL" },
{ "env": "NODE_ENV" }
]
}
}
}
This is the failure I worry about most with any build cache, Nx or otherwise, because it is silent. With a remote cache it gets worse: a developer’s local build with a local .env can populate the shared cache, and CI then restores it. Since I first saw this pattern, every variable a bundler inlines goes into inputs the day it is introduced, and the release pipeline runs its build with --skip-nx-cache so the artifact that ships is always built fresh.
Files outside the project
Root-level config (a shared ESLint config, a Babel or Jest preset, a codegen schema in another folder) must be listed explicitly, either in sharedGlobals or in the target’s inputs:
{ "inputs": ["default", "{workspaceRoot}/schema/api.graphql"] }
A related input type is { "runtime": "node -v" }, which adds the output of a command to the hash. It is useful when the tool version in the environment, rather than in package.json, affects output.
Module boundaries: keeping the graph sane
Once the graph exists, you can enforce rules on it. Tag projects in project.json:
{ "name": "shop-ui", "tags": ["scope:shop", "type:ui"] }
Then constrain dependencies with the @nx/enforce-module-boundaries ESLint rule:
{
"rules": {
"@nx/enforce-module-boundaries": ["error", {
"depConstraints": [
{ "sourceTag": "type:ui", "onlyDependOnLibsWithTags": ["type:ui", "type:util"] },
{ "sourceTag": "scope:shop", "onlyDependOnLibsWithTags": ["scope:shop", "scope:shared"] }
]
}]
}
}
A UI library that imports from a data-access library now fails lint with a message telling you which tag constraint was violated. The same rule also reports circular dependencies between projects and deep imports into another library’s internal files instead of its public entry point.
This matters for more than tidiness. A circular or sprawling graph makes affected include almost everything on every change, which quietly erases the speedup that was the reason for adopting Nx.
Remote cache and distribution
npx nx connect
This connects the workspace to Nx Cloud, which shares the cache between developers and CI and can distribute tasks across multiple CI machines. Everything in the previous section applies twice as strongly here: an undeclared input now produces a wrong hit for the whole team, not just for you. Self-hosted cache options exist too; check the current Nx documentation for what your version supports, as this area has changed across major versions.
Generators
Generators create or modify files through a virtual file tree so changes can be previewed with --dry-run before anything is written:
nx g @nx/react:library --directory=libs/shop-ui --dry-run
A custom generator uses @nx/devkit:
import { Tree, formatFiles, generateFiles, joinPathFragments } from '@nx/devkit';
interface Schema {
name: string;
}
export default async function (tree: Tree, schema: Schema) {
generateFiles(
tree,
joinPathFragments(__dirname, 'files'),
`libs/${schema.name}`,
{ name: schema.name }
);
await formatFiles(tree);
}
Generators are one of the clearest differences from Turborepo. If your team creates new libraries often and wants them all to share the same lint, test and tag setup, a generator makes that the default instead of a checklist.
Upgrading
Nx ships codemods with each release:
nx migrate latest # updates package.json and writes migrations.json
npm install
nx migrate --run-migrations # applies code and config changes
Upgrading one major version at a time and committing after each step makes failures much easier to isolate than jumping several majors at once.
Nx vs Turborepo
| Nx | Turborepo | |
|---|---|---|
| Project graph | Package manifests plus source import analysis, plugins | Package manifests (workspace dependencies) |
| Config | nx.json, optional project.json, inferred targets | turbo.json |
| Cache inputs | Named inputs, env, runtime commands | Files per package, declared env vars |
| Extras | Generators, module boundary lint, migrations, many plugins | Deliberately minimal |
| Non-JS projects | Via plugins | Mostly JS/TS workspaces |
Turborepo is easier to reason about because it does less: tasks are package.json scripts and the graph comes from package dependencies. Nx does more inference, which saves configuration but means you sometimes need nx show project to learn what it decided. For a handful of packages with straightforward builds, I would start with Turborepo or even plain workspaces. For a large repo with many teams, where enforced boundaries and consistent project scaffolding matter as much as speed, Nx’s extra machinery pays for itself.