Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Compare the Nx approach

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.

Nx project graph driving the cached task pipeline Source imports build a project graph of apps and libs; Nx expands it into a task graph honoring dependsOn, then hashes inputs to hit or miss the cache. From source imports to a cached task graph Project graph app: web lib: feature-auth lib: ui, shared Task graph ^build before build topological order parallel where safe Cache lookup hash inputs restore outputs or execute Cache hit replay, skip work Cache miss run, then store A changed input invalidates only its task and everything downstream of it.
Nx derives a project graph from imports, expands it into an ordered task graph, then hashes each task's inputs to hit or miss the cache.

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.
  • namedInputs isolate file-change detection. The production input explicitly excludes test files and spec configs, so a test-only change never invalidates a build cache entry.
  • outputs must 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:

  1. Assign tags in each project's project.json (for example "tags": ["type:feature", "scope:auth"]).
  2. The depConstraints array enforces a directed acyclic graph. A type:ui library cannot import a type:feature library, and a type:app cannot import another type:app.
  3. Setting enforceBuildableLibDependency: true guarantees that only libraries with a valid build target 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=N caps 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=HEAD computes 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 and nx-cloud setup lives in Configuring Nx Affected Commands in CI.
  • Nx v17+ caches by default. Declaring cache: true explicitly in targetDefaults documents 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.

  1. 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
  2. Vulnerability gating. Fail the pipeline on high-severity advisories rather than warning.
    {
      "scripts": {
        "audit:ci": "pnpm audit --audit-level=critical --prod"
      }
    }
  3. 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:

  • --provenance generates a signed SLSA provenance statement for each published package, which requires the id-token: write permission shown on the job.
  • nx release bumps versions, writes changelogs, and creates git tags in one transaction, avoiding the partial-release states that manual scripts fall into.
  • Never commit nxCloudAccessToken or NODE_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

Monorepo Architecture & Orchestration