How nx affected decides what changed and why root files fan out
nx affected answers one question: which projects could produce different output given the files changed between two commits? It gets the list of changed files from git, comparing --base (usually the merge-base with your main branch) to --head, plus any uncommitted changes. It then works in two stages.
First, each changed file is mapped to projects. A file under a project's root belongs to that project. A file outside every project root, such as tsconfig.base.json, .eslintrc.json, or a shared Jest preset, has no owner. Nx handles it by checking every project's configured inputs. Any project whose inputs match that file is marked as directly touched.
Second, Nx walks the project graph and adds every project that depends on a touched project, directly or transitively.
Changed file to affected projects
changed file → matched against project roots or target inputs → directly touched projects → project graph traversal → dependents → affected set
The fan-out happens at the input-matching step. In a typical workspace, the default named input includes sharedGlobals, and most targets inherit default unless they override it. If a root file is listed in sharedGlobals, every project matches it, so every project is directly touched before the graph walk even begins. The dependency graph plays no part in this case. The cause is the input configuration.
This guide is for backend and platform engineers running medium-to-large monorepos, where pull requests often touch root config and a one-line lint rule change should not trigger every build and test in the repository.
+----------------------------------------+
| git: --base vs --head |
| (+ uncommitted changes) |
+-------------------+--------------------+
|
v
+-----------------+
| changed files |
+--------+--------+
|
+-----------+-------------+
| |
v v
+----------------+ +----------------------------+
| file under a | | file outside every root |
| project root | | e.g. tsconfig.base.json |
+-------+--------+ +-------------+--------------+
| |
| ownership | match against each
| | project's target inputs
| | (default -> sharedGlobals)
v v
+----------------------------------------------------+
| directly touched projects |
| root file in sharedGlobals => EVERY project here |
+-------------------------+--------------------------+
| walk project graph to dependents
v
+----------------------------------------------------+
| affected set = touched + transitive dependents |
+----------------------------------------------------+
Changed files reach projects either through ownership or input matching, and a root file listed in sharedGlobals marks every project as touched before the graph walk adds dependents.
Diagnosing what Nx treats as inputs before you change config
Before editing nx.json, find out what your workspace actually does today. Copied snippets from blog posts or older docs often describe a different Nx release. Defaults for inputs, the targets that plugins infer, and how the lockfile is hashed have all changed between versions. The graph your installed Nx produces is the only reliable source.
Inspect resolved inputs per target
Run nx show project <name> --web for a representative project. The project details view lists every target, including ones inferred by plugins that never appear in project.json. For each target it shows the resolved inputs, so you can see whether a root file like .eslintrc.json or tsconfig.base.json is pulled in. It also shows whether that file arrives through sharedGlobals, through default, or through a plugin's own input set. Write down which named input contributes each root file, because that is the layer you will edit later.
nx graph gives the workspace-wide view. Use it to confirm which projects depend on each other before you blame config for a wide fan-out. Sometimes the cause is a real dependency.
Reproduce the fan-out on a clean branch
Create a branch from your main branch that changes exactly one root file. Then run nx show projects --affected --base=main --head=HEAD and nx affected -t build --base=main --head=HEAD. Passing --base and --head explicitly removes any doubt about which commits Nx compares. Save the list of affected projects. That list is your baseline: after each config change, rerun the same commands and check that the list shrinks only where you intended.
#!/usr/bin/env bash
# Capture a baseline of what Nx considers affected before touching nx.json.
set -euo pipefail
# Placeholder project; pick one that rebuilds when it should not.
PROJECT=my-app
# Record the installed Nx version. Defaults differ between releases.
npx nx --version > baseline-nx-version.txt
# Project details view: every target, including plugin-inferred ones,
# with the inputs Nx resolves for each.
npx nx show project "$PROJECT" --web
# Same project config as JSON, so it can be diffed after config changes.
npx nx show project "$PROJECT" --json > baseline-project.json
# Workspace-wide dependency graph. Check for real dependencies
# before blaming inputs for a wide fan-out.
npx nx graph --file=baseline-graph.json
# Branch from main that changes exactly one root file.
git checkout -b nx-inputs-baseline main
echo "" >> tsconfig.base.json # trivial whitespace change
git commit -am "test: touch tsconfig.base.json only"
# Explicit base and head, so there is no doubt which commits are compared.
npx nx show projects --affected --base=main --head=HEAD \
| sort > baseline-affected.txt
# Run the target itself to see the real fan-out.
npx nx affected -t build --base=main --head=HEAD
wc -l baseline-affected.txt
# After each nx.json edit, rerun the affected command and diff the result:
# npx nx show projects --affected --base=main --head=HEAD | sort \
# | diff baseline-affected.txt -
A shell script that records the installed Nx version, the resolved project config and graph, and the list of affected projects for a branch that changes only tsconfig.base.json, so later config edits can be diffed against it.
Modeling root configs with namedInputs, sharedGlobals and per-target inputs
Open nx.json and look at namedInputs. Most workspaces use three entries. default covers every file under {projectRoot} plus sharedGlobals. production starts from default and uses negation patterns to remove files that never reach a build artifact, such as !{projectRoot}/**/*.spec.ts, !{projectRoot}/**/*.test.ts and !{projectRoot}/**/*.md. With those exclusions, editing a test or a README invalidates only the targets that read those files.
sharedGlobals is a naming convention that Nx generators create, not a built-in input with special behavior. Nx hashes whatever you list in it as part of every target that includes default. Put a file there only if it really changes the output of every target. A Node version file such as {workspaceRoot}/.nvmrc qualifies. The root ESLint config does not, because builds never read it.
Every other root file belongs in the inputs of the target that reads it. Set this in targetDefaults so all projects inherit it. Paths need the {workspaceRoot}/ prefix, or Nx resolves them relative to each project. Build-style targets should consume ^production so that a dependency's test changes do not cascade downstream.
| Root file | Consuming targets | Why |
|---|---|---|
.nvmrc |
all (via sharedGlobals) |
Runtime version can change any output |
| ESLint config | lint |
Only the linter reads it |
jest.preset.js / Vitest shared config |
test |
Only test runners load it |
tsconfig.base.json |
build, typecheck |
Compiler options and path mappings change emitted code and type errors |
*.md, *.spec.ts |
none in production |
No effect on artifacts |
{
"namedInputs": {
"sharedGlobals": ["{workspaceRoot}/.nvmrc"],
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/**/*.test.ts",
"!{projectRoot}/**/*.md"
]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production", "{workspaceRoot}/tsconfig.base.json"]
},
"typecheck": {
"inputs": ["production", "^production", "{workspaceRoot}/tsconfig.base.json"]
},
"lint": {
"inputs": ["default", "{workspaceRoot}/eslint.config.mjs"]
},
"test": {
"inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"]
}
}
}
An nx.json that keeps only .nvmrc in sharedGlobals, strips specs and markdown from production, and scopes the root ESLint, Jest and tsconfig files to the targets that read them.
Lockfile, package.json, CI and tooling files
Listing package-lock.json, yarn.lock or pnpm-lock.yaml in sharedGlobals makes every target depend on every byte of the file. A patch bump to a single dev dependency changes the lockfile hash, so every project is affected and every cached result is invalidated. Nx already parses the lockfile into the project graph. When it changes, Nx works out which external packages moved and marks only the projects that depend on them as affected. For tools that a target runs but never imports, such as a test runner or bundler CLI, use an externalDependencies input on that target. The hash then follows that package's resolved version instead of the whole file.
The risk is a missed rebuild. Nx infers dependencies from imports and from each project's package.json. Some packages never show up in either place. A hoisted package that a project uses without declaring it is invisible to the graph. So is a plugin loaded by name from a config string. A bump to either one won't mark the project affected. Declare such packages explicitly, or add them to externalDependencies on the targets that load them.
For the remaining root files:
- CI pipeline definitions such as workflow YAML: leave them out of inputs. They decide which commands run, not what those commands produce.
- Scripts folders: if no target invokes the scripts, leave them out. If a build or codegen target calls them, make the folder a dedicated tooling project. Then add it as a dependency of only the projects whose targets use it.
- Editor configs such as
.editorconfigand IDE settings folders: leave them out. They never reach a build output.
// nx.json (Nx accepts comments in this file)
{
"namedInputs": {
// Only root files that change build output belong here.
// No lockfile: Nx diffs it per package through the project graph.
// No .github/, .editorconfig or .vscode/: they never reach an output.
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts"
]
},
"targetDefaults": {
"test": {
"inputs": [
"default",
"^production",
// The runner is executed, not imported, so the graph cannot see it.
// The hash follows jest's resolved version, not the whole lockfile.
{ "externalDependencies": ["jest", "ts-jest"] }
]
},
"build": {
"dependsOn": ["^build"],
"inputs": [
"production",
"^production",
// Bundler CLI plus a plugin that is loaded by name from a config
// string. Nx cannot infer either one from imports.
{ "externalDependencies": ["vite", "@vitejs/plugin-react"] }
]
}
}
}
An nx.json that keeps the lockfile, CI workflows and editor configs out of sharedGlobals and uses externalDependencies inputs, so a dependency bump only invalidates targets that run the changed tools.
Alternatives: implicitDependencies and when they are the right tool
Per-target inputs and sharedGlobals describe which files a target reads. implicitDependencies does something different: it declares that one project depends on another project when Nx cannot infer the link from imports or package.json. Typical cases are a service that consumes a schema generated by another project, or several deployable apps that read a shared deploy config kept in its own project. Nx has no import statement to follow in either case. Without an explicit edge, a change to the schema project leaves its consumers out of the affected set.
The problem is granularity. An implicit dependency applies to the whole project. If api lists deploy-config, then every target of api (lint, test, build, deploy) becomes affected when deploy-config changes, even though only deploy reads it. Repositories often wrap root files in a project and make it an implicit dependency of everything. That restores the same fan-out you removed by narrowing inputs. It also hides the cause, because the project graph shows the edge but not which file justified it.
| Approach | Granularity | Risk if wrong | Maintenance cost |
|---|---|---|---|
| Per-target inputs | Single target | Missed rebuild if a file is omitted | Medium: kept next to each target |
sharedGlobals |
Every target using the named input | Over-rebuilding if the list grows | Low: one list in nx.json |
implicitDependencies |
Whole project | Over-rebuilding across all targets | Low per edge, grows with each edge added |
| Run everything | Whole workspace | None for correctness | None, but the highest CI cost |
Use implicit dependencies for real project-to-project links that Nx cannot see. Use inputs to model which files a target reads.
When to avoid narrowing inputs and how to stay safe
The two failure modes are not symmetric. If you over-declare inputs, you pay for extra rebuilds. If you under-declare them, Nx can return a cache hit for a target whose real inputs changed, and that stale artifact can pass CI and ship. When you are unsure, keep the file as an input.
Per-target inputs also drift. A list that was accurate when you wrote it goes stale when a tool changes what it reads. For example, a bundler might start honoring tsconfig paths, or a test runner might start picking up a root setup file. Tools also find config through routes that file globs do not show. These include extends chains that point back to a root base config, environment variables, and directory walking that finds the nearest config file above the project.
Narrowing has a second cost. When fewer projects are marked affected, fewer of them get tested, so integration breakage can slip through. Keep a full, uncached run as a backstop, either on every merge to main or on a schedule. This costs one complete build and test cycle each time it runs. You can treat it as the price of trusting a narrower affected set on every other run.
When not to narrow
- Small workspaces. If a full run is already cheap, the risk outweighs the savings.
- Unclear consumers. If you cannot name every tool that reads a root file, leave that file broad.
Every exclusion needs a written reason in the pull request or next to the config. The reason should say which tools read the file and why the excluded targets are unaffected by it.
Verify the change
- Pick a few representative root-file changes. List the affected projects and targets for each change before and after your edit, then compare the lists. Every target that dropped out should be one you can explain.
- Change an excluded file and confirm the targets you excluded still hit the cache.
- Change a file that should still count and confirm the affected targets miss the cache and rebuild.
Impact on CI cost and engineering time
Narrowing inputs is worth doing only if the savings outweigh the upkeep. Use the arithmetic below with your own numbers to check.
Illustrative estimate (assumed numbers, not a benchmark)
Assumptions:
- 60 projects, each with a
buildtarget (average 4 min) and alinttarget (average 0.5 min). - No cache hits.
- 8 tasks run in parallel.
- The tasks are uniform enough that wall-clock time is roughly the number of batches multiplied by the task duration.
Formulas:
- Compute minutes = projects × minutes per task
- Wall-clock time ≈ ⌈projects ÷ parallelism⌉ × minutes per task
| Scenario for an ESLint-config-only PR | Compute minutes | Wall-clock time |
|---|---|---|
| Root config is in default inputs: build and lint run everywhere | 60 × 4.5 = 270 | ⌈60 ÷ 8⌉ × 4.5 = 36 min |
Config is a lint input only |
60 × 0.5 = 30 | ⌈60 ÷ 8⌉ × 0.5 = 4 min |
Each such PR saves about 240 compute minutes and about 32 minutes of waiting.
What it costs to keep
There are two ongoing costs:
- Maintenance. Every new tool, generator or config file needs a deliberate input decision. When someone gets that decision wrong, you get a stale cache hit, and those take time to debug.
- Backstop full builds. A nightly run ignores the cache and rebuilds everything, which costs roughly 270 compute minutes a day under these assumptions.
If ESLint-only changes land about once a day, the backstop cancels most of the compute savings. The engineers still get their 32 minutes back, because the backstop runs while nobody is waiting on it.
The effort pays off when root config changes are frequent and land on the critical path of PRs. If those files change a few times a quarter, the default fan-out is cheaper than maintaining the inputs.
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 affected root config changes, 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.