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.
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.
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.
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.
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:*" } }
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.
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. |
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.
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.