Nx Workspace Architecture
Nx turns a directory of loosely related packages into a deterministic build system: it derives a project graph from your imports, attaches a task pipeline to that graph, and caches every task by a content hash of its inputs. Getting the architecture right means the difference between a monorepo that rebuilds the world on every commit and one that rebuilds only what changed. This page covers the workspace layout, project graph, boundary enforcement, task pipeline, caching, and the release path that production Nx workspaces depend on. It sits within Monorepo Architecture & Orchestration, and the CI half of the story — computing the minimal affected set on every pull request — is covered in depth in Configuring Nx Affected Commands in CI.
Workspace Initialization & Configuration
Initialize a workspace with the integrated TypeScript preset. Choosing the package manager and remote cache at bootstrap avoids a painful retrofit later.
npx create-nx-workspace@latest my-org-monorepo \
--preset=ts \
--style=none \
--nxCloud=yes \
--packageManager=pnpm
Configure nx.json to establish a deterministic layout, caching boundaries, and execution defaults. The structural decisions made here govern long-term maintainability, and they interact directly with how your package manager resolves the workspace — covered in Workspace Configuration Deep Dive.
{
"workspaceLayout": {
"appsDir": "apps",
"libsDir": "libs"
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"cache": true,
"inputs": ["production", "^production"],
"outputs": ["{projectRoot}/dist"]
},
"test": {
"cache": true,
"inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"]
}
},
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": ["default", "!{projectRoot}/**/*.spec.ts", "!{projectRoot}/tsconfig.spec.json"]
}
}
Three configuration notes carry most of the weight:
dependsOn: ["^build"]enforces topological execution order — the caret means "build every dependency project first." Dependencies build before dependents, and Nx parallelizes the rest.namedInputsisolate file-change detection. Theproductioninput explicitly excludes test files and spec configs, so a test-only change never invalidates abuildcache entry.outputsmust map every artifact directory. Omitting them silently breaks both local and remote caching, because Nx has nothing to restore on a cache hit.
An Nx workspace centers on nx.json, which declares the shared task defaults, the named inputs that feed cache keys, and the plugins that teach Nx to understand each language. Getting this configuration right at the start pays off continuously, because every affected calculation, cache key, and boundary check reads from it. The most consequential early decision is how to structure namedInputs — a lean production input that excludes tests and docs from build hashes, and a deliberately minimal sharedGlobals — because those choices determine whether the cache and affected detection stay precise as the workspace grows.
The single most consequential early decision in an Nx workspace is how to structure the named inputs, because they determine whether the cache and affected detection stay precise as the workspace grows. A lean production input that excludes specs, stories, and docs from build hashes means a test-only change does not invalidate a build; a deliberately minimal sharedGlobals means only a genuinely global file — the lockfile, the base tsconfig — invalidates the whole workspace. Getting these right at the start pays off on every subsequent build, because every affected calculation and cache key reads from them.
Project Graph & Boundary Enforcement
The project graph is the source of truth for everything Nx does. It is derived statically from your import/require statements plus explicit implicitDependencies. Visualize it during development to catch architectural drift before it merges:
nx graph
Enforce strict layering with @nx/eslint-plugin. The enforce-module-boundaries rule blocks circular dependencies and unauthorized cross-layer imports at lint time, which is the cheapest place to catch them. When the graph does develop cycles anyway, Debugging Circular Dependencies in Monorepos walks through tracing and breaking them.
{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{
"sourceTag": "type:app",
"onlyDependOnLibsWithTags": ["type:feature", "type:ui", "type:shared"]
},
{
"sourceTag": "type:feature",
"onlyDependOnLibsWithTags": ["type:ui", "type:shared"]
},
{
"sourceTag": "type:ui",
"onlyDependOnLibsWithTags": ["type:shared"]
}
]
}
]
}
}
The tagging strategy that makes this work:
- Assign
tagsin each project'sproject.json(for example"tags": ["type:feature", "scope:auth"]). - The
depConstraintsarray enforces a directed acyclic graph. Atype:uilibrary cannot import atype:featurelibrary, and atype:appcannot import anothertype:app. - Setting
enforceBuildableLibDependency: trueguarantees that only libraries with a validbuildtarget can be consumed by downstream projects, so you never depend on something that cannot actually be built in isolation.
Nx's defining feature is that it builds a project graph by analyzing your imports and configuration, then uses that graph for everything — task ordering, affected detection, and boundary enforcement. Because the graph is inferred rather than hand-maintained, it stays accurate as the code changes, which is what makes Nx's affected commands trustworthy: the graph the analyzer produces is the graph the scheduler executes. The cost is that Nx must understand your file types, which is why language and framework plugins matter so much in the Nx model.
Boundary enforcement turns that graph into an architectural guardrail. By tagging each project — scope:billing, type:ui, type:feature — and configuring a module-boundary lint rule, Nx forbids illegal edges, so a UI project importing a feature project fails lint rather than shipping. This keeps the graph acyclic and the architecture intact as the workspace grows, and it makes an attempt to cross a boundary a design signal: usually the shared code wants extracting into a lower-layer project both sides can depend on.
Boundary enforcement turns the inferred project graph into a living architectural constraint rather than documentation that drifts. By tagging projects along the axes that matter — a scope like scope:billing and a type like type:ui or type:feature — and configuring the module-boundary lint rule to forbid illegal combinations, an import that violates the intended architecture fails lint on the pull request that introduced it. This keeps the graph acyclic and the layering intact as the workspace grows, and it makes each boundary violation a design conversation at the moment it happens rather than a slow erosion nobody notices.
The rules also encode which projects may depend on sensitive shared code, which is a security as much as an architecture concern. A shared authentication or billing library can be tagged so only the projects that legitimately need it may import it, and an attempt by an unrelated project to reach it fails lint. Because the rule reads the same project graph the scheduler and cache use, enforcing boundaries costs nothing extra at build time — it is a lint pass over a graph Nx already maintains — and it turns architectural intent into something the tooling guarantees rather than something reviewers must remember to check.
Task Pipeline & Execution Strategy
The task pipeline is the project graph plus the dependsOn rules, expanded into an ordered set of tasks. Nx walks it topologically and runs independent tasks in parallel up to your concurrency cap. For teams weighing engines, compare Nx's task runner against Turborepo Pipeline Configuration, review the head-to-head numbers in Nx vs Turborepo Performance Benchmarks, and use Choosing a Monorepo Task Runner to map the trade-offs onto your repo's shape.
Standardize CI execution with explicit parallelism limits and affected-scope targeting:
{
"scripts": {
"build:all": "nx run-many --target=build --all --parallel=4",
"test:affected": "nx affected --target=test --base=origin/main --head=HEAD --parallel=3",
"lint:strict": "nx run-many --target=lint --all --parallel --max-warnings=0"
}
}
The flags that matter in CI:
--parallel=Ncaps concurrency to match runner vCPU count. Uncapped parallelism is the most common cause of OOM kills and flaky tests on shared runners.nx affected --base=origin/main --head=HEADcomputes the minimal set of projects impacted by the current branch by diffing the graph against the base. On a large repo this is the single biggest CI time saving; the full base/head andnx-cloudsetup lives in Configuring Nx Affected Commands in CI.- Nx v17+ caches by default. Declaring
cache: trueexplicitly intargetDefaultsdocuments intent and protects against behavioral regressions across CLI upgrades.
Nx models tasks with targetDefaults and per-project targets, and dependsOn expresses cross-project ordering the same way a task runner does: ^build means build every project I depend on first. The scheduler derives a topological order from the project graph and runs independent targets in parallel, so a run-many or affected invocation executes the whole set in dependency order with cache-aware parallelism. The pipeline is declarative — you describe the dependencies, and Nx computes the execution.
Executors are the other half of the pipeline, wrapping the actual command a target runs. A target references an executor (a build, a test, a lint) with configuration, and because executors are plugins, Nx can add cross-cutting behavior — caching, incremental builds, distributed execution — around a command without each project reimplementing it. This plugin model is what lets Nx offer capabilities like distributed task execution that a plain script runner would leave you to build yourself.
Distributed task execution is where Nx's graph pays off at scale. Because the scheduler knows the full dependency order and each task's cache key, it can farm tasks out across a fleet of agents, replay cached results between them, and reassemble the outputs — so a large affected set that would serialize on one machine runs in parallel across many. This is the same topological, cache-aware model applied across machines rather than cores, and it is what keeps CI time bounded as both the repo and the team grow.
Executors are what let Nx add cross-cutting behavior around a command without each project reimplementing it. A target references an executor with configuration, and because executors are plugins, capabilities like caching, incremental builds, and distributed execution wrap the underlying command uniformly across every project that uses that executor. This is the difference between Nx and a plain script runner: the plugin model means a capability added once — say, distributed execution — applies to every project's build without touching each project's scripts, which is the platform leverage that justifies Nx's extra configuration surface on a large workspace.
Caching Internals & Cache Correctness
A cache is only safe if its key captures every input that can change a task's output. Nx computes the key from the named inputs you declared, the task's command, the project graph position, and relevant global files. Two failure modes dominate:
- Under-specified inputs produce stale hits: a file that affects output is not in the hash, so Nx replays an outdated artifact. Add the missing path to
namedInputs. - Over-specified inputs produce thrashing: an irrelevant file (a changelog, a lockfile that should be a global) is in the hash, so every commit misses. Move it out of the project input or into a tightly scoped
sharedGlobals.
Lockfile contents feed cache keys, so a noisy or non-deterministic lockfile undermines hit rates across the team. Keep it clean and conflict-free using the practices in Lockfile Management Strategies. For scoping installs and graph operations to a subset of packages, Nx's --projects flag pairs naturally with pnpm Workspace Filtering.
Tuning the named inputs is what keeps the cache both precise and correct. A lean production input excludes tests and docs from build hashes so a spec change does not invalidate a build, while sharedGlobals is kept minimal because everything in it invalidates the whole workspace:
// nx.json
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/**/*.stories.ts",
"!{projectRoot}/**/*.md"
],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
},
"targetDefaults": { "build": { "inputs": ["production", "^production"] } }
}
With build targets pointing at the production input, only production sources invalidate a build, and the deliberately-small sharedGlobals means a change to a genuinely global file — and only such a file — invalidates everything. Getting these inputs right is the difference between a cache that accelerates and one that either misses on irrelevant changes or replays stale results.
Dependency Isolation & Supply-Chain Hardening
Nx delegates installation to the package manager, so isolation discipline lives in your manifests, not in nx.json.
- Lockfile enforcement. Validate the lockfile in a pre-commit hook to prevent drift and tampering.
# .husky/pre-commit npx lockfile-lint --path pnpm-lock.yaml --type pnpm --validate-https - Vulnerability gating. Fail the pipeline on high-severity advisories rather than warning.
{ "scripts": { "audit:ci": "pnpm audit --audit-level=critical --prod" } } - Workspace protocol. Use
workspace:*for internal dependencies so they resolve to local symlinks and never silently pull a published version, which closes off phantom-dependency injection.{ "dependencies": { "@org/shared-utils": "workspace:*" } }
Nx's project graph doubles as a supply-chain control surface. Because it knows every project's declared dependencies, it can enforce that projects use only what they declare and constrain which projects may depend on sensitive shared code through boundary rules. Layered with the standard hardening — frozen installs, ignored scripts, an audit threshold, and host allow-listing — the workspace keeps its dependency surface both isolated between projects and controlled against external threats, so a compromised dependency cannot silently spread across the graph.
Release & Artifact Management
Structure output directories for deterministic deployments by mapping outputs in nx.json to dist/ paths. Nx provides nx release for version bumping, changelog generation, and tagging as an atomic operation (Nx 17+).
name: Release & Publish
on:
push:
tags: ['v*']
permissions:
id-token: write
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: pnpm install --frozen-lockfile
- run: nx run-many --target=build --all --parallel=4
- name: Verify artifact integrity
run: |
find dist -type f -name "*.js" -exec sha256sum {} \; > dist/checksums.sha256
sha256sum -c dist/checksums.sha256
- name: Publish packages
run: npx nx release publish --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Key practices:
--provenancegenerates a signed SLSA provenance statement for each published package, which requires theid-token: writepermission shown on the job.nx releasebumps versions, writes changelogs, and creates git tags in one transaction, avoiding the partial-release states that manual scripts fall into.- Never commit
nxCloudAccessTokenorNODE_AUTH_TOKEN. Inject both through CI secrets.
Artifact management in Nx benefits from the same graph that drives builds. Because Nx knows each project's outputs and dependencies, it can cache build artifacts, restore them for downstream consumers, and — with distributed execution — share them across a fleet of runners so a project built on one machine is reused everywhere. This turns the artifact from something each job rebuilds into something computed once and replayed, which is the same economics as source caching applied to the compiled output.
Common Pitfalls & Remediation
| Mistake | Impact | Remediation |
|---|---|---|
Omitting outputs in targetDefaults |
Breaks local and remote caching; forces full rebuilds every run, inflating CI cost. | Map all build/test output directories to {projectRoot}/dist or {projectRoot}/coverage. |
Overusing implicitDependencies |
Cache thrashing and needless task execution across unrelated projects. | Replace with explicit dependsOn and input globs; reserve implicitDependencies for global config files like tsconfig.base.json. |
| Unrestricted cross-boundary imports | Circular dependencies, non-deterministic builds, hidden coupling that blocks package extraction. | Configure enforce-module-boundaries with strict tag-based depConstraints. |
nx run-many with no --parallel cap |
OOM kills and flaky tests on shared CI runners. | Set --parallel=4 or match the runner CPU count. |
| Treating the lockfile as outside the cache key | Stale or thrashing caches across the team. | Keep the lockfile deterministic and conflict-free; let Nx hash it as a global. |
The recurring Nx pitfalls trace back to the graph and the inputs. An affected set that is too large usually means an over-broad sharedGlobals or a shallow clone with an unreachable base; a cache that never hits means an input that varies between environments; a boundary violation that ships means the lint rule is not wired into CI. Each is diagnosable with a specific command — nx affected:graph for the affected set, a cache summary for hash inputs, the lint run for boundaries — so the remediation is to read what Nx computed rather than guess at the configuration.
Caching internals and cache correctness
Nx's computation cache is what makes a large workspace fast, and understanding its key explains both its speed and its correctness guarantees. For each task, Nx computes a hash from its named inputs — the project's source files, its dependencies' relevant files, the task configuration, and any declared runtime inputs like tool versions or environment variables — and uses that hash to store and later replay the task's file outputs and terminal output. A cache hit is provably equivalent to a rerun because the hash captures everything that could change the result, so the replayed output is byte-identical to what a fresh run would produce.
Correctness hinges on the named inputs being complete and precise. An input set that is too narrow omits something that affects the output, so a stale result replays; one that is too broad folds in unrelated files, so the cache misses on changes that do not matter. The production named input is the common tuning point — excluding spec files, stories, and markdown from build inputs so a test-only change does not invalidate a build — while sharedGlobals must be kept minimal because every file listed there invalidates the whole workspace. Getting the inputs right is the difference between a cache that accelerates and one that either lies or never hits.
Dependency isolation and release management
Nx addresses supply-chain and release concerns at the workspace level. Because the project graph knows every project's dependencies, Nx can enforce that projects only use what they declare, and its boundary rules double as a way to constrain which projects may depend on sensitive shared code. Combined with the standard hardening — frozen installs, ignored scripts, an audit threshold — the workspace's dependency surface stays both isolated between projects and controlled against external threats.
Release management uses the same graph to version and publish coherently. Nx's release tooling computes each publishable project's next version from its changes, walks the graph to include projects that must move because a dependency did, generates changelogs, and publishes — the monorepo release problem the graph is uniquely suited to solve. The through-line across Nx's architecture is that one inferred, accurate project graph powers everything: ordering, affected detection, boundary enforcement, caching, and release. Investing in keeping that graph honest — declared dependencies, enforced boundaries, precise inputs — is what makes every Nx capability trustworthy, because they all read from the same source of truth.
Frequently Asked Questions
How do I enforce strict dependency boundaries without breaking local development?
Configure @nx/eslint-plugin enforce-module-boundaries with a temporary allow array for in-flight migration paths, keep enforceBuildableLibDependency: true, and run nx lint --fix in a pre-commit hook so violations are corrected before they merge. Use nx graph to verify the graph stays acyclic.
What is the correct way to configure remote caching for CI?
Set NX_CLOUD_ACCESS_TOKEN as a CI secret rather than committing it, declare cache: true in targetDefaults, and make sure every cached target has explicit outputs so the cache key stays consistent. Then run affected commands against a real base ref as shown in Configuring Nx Affected Commands in CI.
How does Nx handle workspace dependency resolution compared to pnpm or npm?
Nx does not touch node_modules; it delegates installation to the package manager and builds its own project graph purely for task execution and caching. Combine Nx's --projects filtering with the workspace: protocol to isolate internal dependencies and prevent phantom-dependency injection.
Why is my Nx cache missing on every run even though nothing changed?
A file that should be a shared global — a lockfile, a root tsconfig, an env file — is being hashed as a per-project input, so it invalidates the key constantly. Move it into a narrowly scoped sharedGlobals named input, or exclude it from the production input if it does not affect output.
How does Nx keep its project graph accurate?
It infers the graph by analyzing imports and configuration rather than requiring hand-maintained declarations, so it updates as the code changes. That is why Nx's affected commands are trustworthy — the graph the analyzer produces is the one the scheduler executes — but it requires plugins that understand your file types.
Why does a spec-file change invalidate my Nx build cache?
Because the build target's named inputs include the spec files. Define a lean production input that excludes *.spec.ts, stories, and markdown, and point build targets at it so only production sources invalidate the build.
What belongs in Nx sharedGlobals?
Only files that genuinely affect every project's output — typically the lockfile and the base tsconfig. Everything listed there invalidates the whole workspace on any change to it, so keep the set minimal and deliberate.
Is Nx worth adopting for a small workspace?
Usually not until you feel the pain Nx solves — slow full-rebuilds, phantom dependencies, or a need for enforced boundaries and codegen. A small JavaScript workspace is well served by simpler tooling first; adopt Nx's platform when the workspace grows toward many projects or multiple languages where its project graph and distributed execution pay off.
What's the first thing to configure in a new Nx workspace?
The named inputs in nx.json — a lean production input that excludes tests and docs from build hashes, and a minimal sharedGlobals. Those choices determine whether the cache and affected detection stay precise as the workspace grows, so getting them right early pays off on every build.
How does Nx keep its project graph accurate?
It infers the graph by analyzing imports and configuration rather than requiring hand-maintained declarations, so it stays current as the code changes. That is why its affected commands are trustworthy — but it depends on plugins that understand your file types and on declared imports the analyzer can follow.
Related
- Configuring Nx Affected Commands in CI — the full base/head and Nx Cloud setup for computing the minimal affected set on every pull request.
- Turborepo Pipeline Configuration — the pipeline model in the main alternative engine, for direct comparison with Nx's
targetDefaults. - Nx vs Turborepo Performance Benchmarks — measured cache-hit rates and cold/warm build times across both runners.
- Choosing a Monorepo Task Runner — a decision framework for picking between Nx, Turborepo, and package-manager-native execution.
- pnpm Workspace Filtering — scoping installs and tasks to a package subset, which complements Nx's
--projectsflag.