How to configure Nx affected commands for faster PR checks

Full monorepo rebuilds on every pull request burn compute and delay feedback. nx affected solves this by traversing the project dependency graph and executing only the targets touched by the current diff — typically cutting PR validation time by 40–70% once correctly configured.

When to use this pattern

  • Your Nx workspace contains more than five projects and full-rebuild CI runs exceed five minutes.
  • PRs routinely change one or two packages but trigger lint, test, and build across the entire repository.
  • You need a deterministic, graph-aware narrowing strategy that respects implicit dependencies on shared config files.

Prerequisites


How nx affected resolves the project set

Before configuring flags, understanding the resolution mechanics prevents the most common misconfigurations.

Nx builds a project dependency graph (a directed acyclic graph) from project.json files and the imports it detects in source code. When you run nx affected, it computes the set of changed files between --base and --head, maps each file to its owning project, then walks the graph forward to include every downstream dependent.

The result is a minimal execution set: projects whose own source changed, plus every project that depends on them, directly or transitively.

Nx affected dependency graph walk A pull request changes shared-utils. Nx walks forward through the dependency graph: api-server depends on shared-utils and is marked affected; web-app depends on api-server and is also marked affected. auth-lib has no path to shared-utils and is skipped. shared-utils source changed CHANGED api-server depends on shared-utils AFFECTED web-app depends on api-server AFFECTED worker depends on api-server AFFECTED auth-lib no path from shared-utils SKIPPED

auth-lib is skipped entirely — its lint and test targets do not run. That is the performance gain.


Complete working example

The examples below demonstrate a production-ready configuration for both GitHub Actions and GitLab CI. The key difference between them is how each platform exposes the PR base SHA.

GitHub Actions

name: PR Validation
on:
  pull_request:
    branches: [main, develop]

jobs:
  affected-check:
    runs-on: ubuntu-latest
    steps:
      # fetch-depth: 0 is mandatory — shallow clones prevent SHA comparison
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      # Restore the Nx computation cache between runs
      - uses: actions/cache@v4
        with:
          path: .nx/cache
          # Hash package-lock to bust the cache on dependency changes
          key: nx-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
          restore-keys: nx-${{ runner.os }}-

      - name: Run affected lint and test
        run: |
          # github.event.pull_request.base.sha is the merge-base on GitHub
          # --targets runs multiple targets in one pass (Nx 16+)
          npx nx affected \
            --targets=lint,test \
            --parallel=3 \
            --maxParallel=3 \
            --base=${{ github.event.pull_request.base.sha }} \
            --head=${{ github.sha }}

      - name: Run affected build
        run: |
          # Separate build step so failed tests don't block artefact upload
          npx nx affected \
            --targets=build \
            --parallel=2 \
            --base=${{ github.event.pull_request.base.sha }} \
            --head=${{ github.sha }}

GitLab CI

GitLab does not expose a pre-computed merge-base SHA. Use git merge-base to derive it from the target branch.

nx_affected:
  stage: test
  image: node:20-alpine
  cache:
    key: nx-$CI_COMMIT_REF_SLUG-$CI_MERGE_REQUEST_TARGET_BRANCH_NAME
    paths:
      - .nx/cache
      - node_modules/.cache
  before_script:
    - npm ci
    # Fetch the target branch so merge-base has enough history to compare
    - git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
  script:
    # Compute the true common ancestor, not the branch tip
    - BASE=$(git merge-base HEAD origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME)
    - npx nx affected --targets=lint,test --parallel=2 --base=$BASE --head=$CI_COMMIT_SHA
    - npx nx affected --targets=build --parallel=2 --base=$BASE --head=$CI_COMMIT_SHA
  rules:
    # Only run on merge request pipelines, not branch pushes
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

Implicit dependency mapping in nx.json

When shared root files change (lockfile, tsconfig base, nx.json itself), every project should be re-evaluated. Configure namedInputs in nx.json:

{
  "namedInputs": {
    "sharedGlobals": [
      "{workspaceRoot}/package-lock.json",
      "{workspaceRoot}/tsconfig.base.json",
      "{workspaceRoot}/nx.json"
    ],
    "default": [
      "sharedGlobals",
      "{projectRoot}/**/*"
    ]
  },
  "targetDefaults": {
    "build": {
      "inputs": ["default", "^default"],
      "cache": true
    },
    "test": {
      "inputs": ["default", "^default"],
      "cache": true
    },
    "lint": {
      "inputs": ["default"],
      "cache": true
    }
  }
}

The ^default prefix pulls in inputs from all upstream projects, so a change in a library’s source correctly marks its consumers as affected.


Step-by-step walkthrough

Checkout configuration

The fetch-depth: 0 flag is non-negotiable. Nx computes the affected set via git diff --name-only $BASE $HEAD. With a shallow clone of depth 1 or 50, the base SHA is frequently absent from the local history, causing nx affected to silently fall back to treating all projects as affected — exactly what you are trying to avoid.

Base SHA resolution

On GitHub Actions, github.event.pull_request.base.sha is the SHA of the last commit on the target branch at the time the PR was opened. This is the correct merge-base for most workflows.

On GitLab CI, git merge-base HEAD origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME computes the true common ancestor. Do not use CI_MERGE_REQUEST_DIFF_BASE_SHA without verifying it matches the merge-base in your repository’s branching model.

Target flags

--targets=lint,test runs both targets in a single graph traversal (Nx 16+). Pre-16 workspaces require two separate invocations. Splitting build into a second step (as shown) prevents a failing test from blocking artefact generation when your pipeline needs both for reporting.

Parallelism tuning

--parallel enables concurrent execution across the affected project set. --maxParallel caps the concurrency. For typical ubuntu-latest runners (2 vCPUs), --maxParallel=3 is a safe ceiling for CPU-bound TypeScript compilation. For Jest or ESLint, --maxParallel=4 is usually safe because those tasks spend time waiting on file I/O. Monitor runner memory: workspaces exceeding 80 projects sometimes OOM at --maxParallel=6 or higher.

Implicit dependency mapping

The namedInputs.sharedGlobals array lists files that, when changed, should mark every project as affected. Without this, a lockfile bump or a change to tsconfig.base.json silently skips re-validation of downstream projects — a regression risk that accumulates over time and aligns with the broader incremental builds and affected detection principles this pattern builds on.


Verification

Run these commands to confirm the configuration is working correctly before merging:

# Preview which projects nx affected would target — executes nothing
npx nx affected --targets=test --base=origin/main --head=HEAD --dry-run

# Expected output (your project names will differ):
# NX  Affected criteria defaulted to --base=origin/main --head=HEAD
#
#  >  NX  Running target test for 3 project(s):
#
#  - api-server
#  - shared-utils
#  - web-app
# Visualise the full dependency graph in your browser
npx nx graph

# Confirm the dependency edges exist between your projects
# In the browser UI, select a changed project and verify
# its downstream consumers are highlighted.
# Check that implicit dependencies are wired up:
# Modify tsconfig.base.json (a whitespace change is enough),
# then verify all projects appear in the dry-run output.
echo "" >> tsconfig.base.json
npx nx affected --targets=test --base=HEAD~1 --head=HEAD --dry-run
git checkout -- tsconfig.base.json

If a project you expected to see is missing from the dry-run output, inspect its project.json for missing implicitDependencies entries and verify namedInputs is configured in nx.json.


Common pitfalls

Shallow checkout causes all projects to be marked affected

Symptom: nx affected consistently reports every project, or reports zero projects when changes exist.

Root cause: The CI checkout used fetch-depth: 1 (the GitHub Actions default). The base SHA is absent from local history, so Nx cannot diff correctly.

Fix: Set fetch-depth: 0 unconditionally. For large repositories where this is too slow, fetch enough history to cover the PR depth:

- uses: actions/checkout@v4
  with:
    # Fetch at least as many commits as the longest PR branch
    fetch-depth: 100

Prefer 0 unless repository size makes full history a meaningful problem (repositories over 5 GB of history).

Missing implicitDependencies for shared config files

Symptom: A change to tsconfig.base.json or package-lock.json does not trigger re-validation of dependent projects, allowing a broken TypeScript config to reach production undetected.

Fix: Add the files to namedInputs.sharedGlobals in nx.json as shown in the complete example above. Verify with the tsconfig.base.json whitespace test from the Verification section. This pattern directly supports the build optimization caching strategies goal of deterministic, complete validation.

Cache key collisions causing stale test results

Symptom: PRs restore an incorrect Nx cache and mark targets as complete without re-executing them, masking regressions.

Root cause: The cache key does not include the lockfile hash, so a dependency upgrade reuses the pre-upgrade cache.

Fix: Always include hashFiles('package-lock.json') (or yarn.lock / pnpm-lock.yaml) in the cache key. Add a conditional npx nx reset step that fires when the lockfile diff is non-empty:

- name: Reset Nx cache on lockfile change
  if: steps.lockfile-diff.outputs.changed == 'true'
  run: npx nx reset

This is especially important when also using remote build caching with Turborepo or Nx Cloud in a mixed toolchain — stale local cache can shadow remote artifacts with different content hashes.


Frequently Asked Questions

How do I handle nx affected when PRs target non-default branches?

Resolve the base dynamically: BASE=$(git merge-base HEAD origin/$TARGET_BRANCH). Wrap this in a shell step that reads the target branch name from an environment variable your CI platform exposes (GITHUB_BASE_REF on GitHub Actions, CI_MERGE_REQUEST_TARGET_BRANCH_NAME on GitLab). Avoid hardcoding origin/main — release branches and feature integrations routinely target other branches, and a hardcoded base produces incorrect affected sets for those workflows.

What is the optimal --maxParallel setting for nx affected in CI?

Start with the runner’s vCPU count as a ceiling. A 2-core ubuntu-latest runner handles --maxParallel=3 for CPU-bound targets without meaningful contention. For I/O-bound targets like Jest, --maxParallel=4 is usually safe. Raise the limit incrementally and watch runner memory: a workspace with 50+ projects running TypeScript compilation in parallel can spike past 6 GB, which exceeds the GitHub-hosted runner limit and causes OOM kills that look like flaky tests.

How do I prevent nx affected from missing changes in shared config files?

Use namedInputs.sharedGlobals in nx.json to list every file whose change should invalidate all projects. Common candidates: package-lock.json, tsconfig.base.json, .eslintrc.json (if shared), and nx.json itself. Reference sharedGlobals from the default named input, then use "inputs": ["default"] in targetDefaults. Confirm coverage with the whitespace-mutation test in the Verification section.

Can nx affected integrate with remote caching?

Yes. Remote caching (via Nx Cloud or a self-hosted server) and affected detection are orthogonal features. nx affected decides which projects run; the cache decides whether each project’s target re-executes or restores a prior artifact. Combining both layers is the most efficient configuration: affected detection skips unrelated projects entirely, and the cache serves cached outputs for projects that were affected but whose inputs have not changed since the last run.


← Back to Incremental Builds and Affected Detection in Monorepos