What Nx sync generators are and the drift they fix

A sync generator is an Nx generator that keeps a file consistent with the current state of the workspace. Its job is to bring an existing file back into agreement with the workspace, rather than to create something new.

Some workspace files are really a function of the project graph. TypeScript project references are the clearest case. If orders-api imports from shared-db, then orders-api's tsconfig should reference shared-db. That fact already exists in the graph Nx builds from your imports and project configuration. When engineers maintain a second copy by hand, the copy drifts:

  • Someone adds an import and forgets the reference.
  • A library is renamed or deleted and the stale entry remains.

The resulting failures rarely name the real cause. tsc --build may skip a dependency or report missing declaration files. Type-checking may pass locally because of leftover build output and then fail on a clean CI runner.

A sync generator removes the hand-maintained copy. It recomputes the derived file from the graph every time it runs, so the file cannot quietly fall behind.

Scaffolding generator Sync generator
Purpose Create new files or projects Keep derived files consistent
Input User-supplied options Current workspace state
Run frequency Once, on demand Repeatedly, before tasks or in CI
Idempotent Not necessarily Yes; a second run changes nothing
Output Generated files Updated files plus an out-of-sync report

The biggest beneficiaries are backend and platform engineers running TypeScript monorepos with many interdependent services and libraries. The underlying idea is simple: the project graph is the single source of truth, and config files are outputs derived from it.

How sync generators work: task-level vs global registration

Every sync generator follows the same loop. Nx computes the project graph and passes it to the generator alongside a virtual file system, the Tree. The generator compares what the graph implies with what is on disk and writes corrections to the Tree, not directly to files. Because the changes are buffered, Nx can take one of two actions with the same result: write them to disk, or discard them and report that the workspace has drifted. That is how one generator serves both a developer's laptop and a CI check.

Task-level registration

A task-level generator is listed on a target, either in a project's configuration or in target defaults. Nx runs it before that task executes. This suits config that a specific task depends on. For example, tsc reads tsconfig references, so those references must be correct before a type-check or build starts. The generator only runs when the task that needs it runs.

Global registration

A global generator is registered in nx.json and covers the whole workspace. It is for config that no single task owns, such as root-level files and CODEOWNERS-style mappings. Nx does not run global generators before tasks. They run when you invoke nx sync or nx sync:check.

Apply, prompt, or check

When a task-level generator detects drift before a task, Nx's behavior depends on configuration and context. In an interactive terminal it can prompt you to apply the changes, or apply them automatically if you have configured that. In non-interactive environments such as CI, it reports the drift and fails rather than editing files. nx sync:check gives you this check-only behavior explicitly.

The relevant nx.json keys and their defaults have changed across Nx releases. Check them against the documentation for your installed version.

{
  // nx.json (Nx parses comments in this file; keys shown match recent releases, so verify against your installed version)
  "targetDefaults": {
    "build": {
      // Task-level: runs before every `build` task so tsconfig project references
      // match the project graph before tsc reads them
      "syncGenerators": ["@nx/js:typescript-sync"]
    },
    "typecheck": {
      "syncGenerators": ["@nx/js:typescript-sync"]
    }
  },
  "sync": {
    // Global: no task owns these files, so they run only via `nx sync` / `nx sync:check`
    // (placeholder generator from a local workspace plugin)
    "globalGenerators": ["@myorg/workspace-tools:codeowners-sync"],
    // true = apply detected drift without prompting in an interactive terminal;
    // CI still only reports drift and fails
    "applyChanges": true
  }
}

An nx.json that registers a task-level sync generator on build and typecheck targets and a global generator for workspace-wide files; key names and defaults vary by Nx release.

Using the built-in TypeScript project references sync

In a workspace set up for TypeScript project references, the @nx/js plugin provides a sync generator (@nx/js:typescript-sync) that maintains the references arrays for you. It touches two places. The root tsconfig.json gets one reference per TypeScript project, so a single tsc --build from the root covers the whole workspace. Each project's tsconfig gets references to the tsconfigs of the projects it depends on, based on the Nx project graph.

The dependency graph drives this because tsc --build uses references to decide build order and which declaration outputs to read. If a reference is missing, TypeScript may resolve an import against stale .d.ts files or fail to find them. If a reference is extra, unrelated projects get rebuilt and you can create cycles that build mode rejects. Nx already knows the real dependencies from your imports and package.json files. Deriving references from that graph means they reflect your actual code, not what someone remembered to edit.

Day to day, run nx sync after adding or removing a dependency to write the updated references. Run nx sync:check to report drift and exit non-zero without changing any files. That makes it suitable for pre-commit hooks and CI.

Don't rely on defaults. Declare the behavior in the sync section of nx.json: register the generator explicitly and state whether tasks should apply changes automatically or prompt first. Confirm the exact key names against the documentation for your Nx version before committing them.

# Excerpt of the explicit sync settings in nx.json (verify key names for your Nx version):
#   "sync": {
#     "applyChanges": true,
#     "globalGenerators": ["@nx/js:typescript-sync"]
#   }
# applyChanges: true writes sync changes before tasks run, with no prompt.
# globalGenerators makes 'nx sync' run the TypeScript sync for the whole workspace.

# After adding or removing a dependency between projects, rewrite the
# references arrays in the root and project tsconfig files from the graph.
npx nx sync

# Review what changed. The references should match the new imports.
git diff -- tsconfig.json '**/tsconfig*.json'

# Build mode follows those references to set build order and read .d.ts outputs.
npx tsc --build

# In CI or a pre-commit hook, detect drift without writing any files.
# It exits non-zero if the tsconfig references are out of sync with the graph.
npx nx sync:check

The daily loop for Nx TypeScript project references sync: an explicit sync block in nx.json, nx sync to apply reference updates before tsc --build, and nx sync:check to catch drift in CI without changing files.

Writing a custom sync generator step by step

Start by deciding what the generator owns. A good candidate is a file derived entirely from project metadata, such as a CODEOWNERS fragment built from team:* tags or a service registry built from type:service projects. If humans also edit the file by hand, the generator and the humans will overwrite each other.

The generator itself is a function that receives a Tree:

  1. Read the graph. Call createProjectGraphAsync from @nx/devkit and filter projects by tag. Read tags from the graph rather than from individual project.json files, because the graph also includes inferred projects.
  2. Compute the contents deterministically. Sort projects by name, sort tags within each entry, and emit a fixed header. If the output is unstable, every run looks like drift, and CI fails for no real reason.
  3. Write through the Tree. Compare the computed contents with what is on disk, and call tree.write only when they differ. Because writes are staged in the Tree, nx sync --check can report drift without touching the filesystem.
  4. Return a useful message. Set outOfSyncMessage to something actionable, for example: "CODEOWNERS fragment is missing payments-api (tag team:payments)." A bare "out of sync" message forces people to diff the file themselves.

Next, add the generator to the global sync generator list in nx.json, because the file belongs to the workspace and not to any single task.

For testing, have the generator accept the graph as an optional parameter. This lets tests pass a fabricated graph alongside createTreeWithEmptyWorkspace. Then assert two properties:

  • Idempotence: a second run changes nothing in the Tree.
  • Convergence: after one sync, a check-mode run returns no message.
import { Tree, ProjectGraph, createProjectGraphAsync } from '@nx/devkit';
import { SyncGeneratorResult } from 'nx/src/utils/sync-generators';

// Register in nx.json: { "sync": { "globalGenerators": ["@acme/tools:codeowners"] } }
const OUTPUT = '.github/CODEOWNERS.generated';
const HEADER = '# Generated from team:* tags by @acme/tools:codeowners. Do not edit.\n';
const byString = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); // locale-independent

// Pure core: tests pass a fabricated graph plus createTreeWithEmptyWorkspace().
export function syncCodeowners(tree: Tree, graph: ProjectGraph): SyncGeneratorResult {
  const lines = Object.values(graph.nodes)
    .map((node) => ({
      name: node.name,
      root: node.data.root,
      owners: (node.data.tags ?? [])
        .filter((tag) => tag.startsWith('team:'))
        .map((tag) => `@acme/${tag.slice('team:'.length)}`)
        .sort(byString),
    }))
    .filter((p) => p.owners.length > 0)
    .sort((a, b) => byString(a.name, b.name)) // stable order => no false drift
    .map((p) => `/${p.root}/ ${p.owners.join(' ')}`);

  const desired = HEADER + lines.join('\n') + '\n';
  const current = tree.exists(OUTPUT) ? tree.read(OUTPUT, 'utf-8') ?? '' : '';
  if (current === desired) return {}; // in sync: no write, no message

  tree.write(OUTPUT, desired); // staged in the Tree; --check reports it without writing
  const missing = lines.filter((line) => !current.includes(line));
  return {
    outOfSyncMessage: missing.length
      ? `${OUTPUT} is missing or outdated for: ${missing.join('; ')}`
      : `${OUTPUT} contains entries for projects that no longer have team:* tags`,
  };
}

export default async function (tree: Tree): Promise<SyncGeneratorResult> {
  return syncCodeowners(tree, await createProjectGraphAsync());
}

A global sync generator that builds a CODEOWNERS fragment from team: tags with deterministic ordering, writes through the Tree only when the content differs, returns an actionable outOfSyncMessage, and exposes a graph-injectable core function so tests can check idempotence and convergence.*

Enforcing consistency in CI and migrating an existing repo

Those same properties are what make enforcement safe. Run nx sync:check as one of the first CI steps, before build, lint, or test. It exits non-zero when any registered generator would change a file. When it fails, the log names the stale configuration directly. Without the check, the same drift shows up twenty minutes later as an unresolved import or a confusing type error, and someone has to work backwards to the missing project reference.

Locally, the sync options in nx.json decide what happens when a task finds drift:

Local mode Behaviour Friction Surprise
Auto-apply Writes fixes before the task runs None during the run Unexplained changes appear in git status
Prompt Asks before writing One interruption per drift Low, but non-interactive shells need a default
Check-only Reports drift, writes nothing Developer must run nx sync manually None. Failures are explicit.

Auto-apply suits teams that already trust the generators. Prompt is a reasonable default while that trust is still forming.

Migrating an existing repo

  1. Run nx sync once on a clean branch.
  2. Open a PR containing only that diff. Feature work mixed into it would be impossible to review. The diff is usually large because it captures years of manual drift. Reviewers should confirm that it is mechanical, not read every line.
  3. After it merges, turn on the CI check. From then on, each sync diff is scoped to the change that caused it.

Finally, mark generated output clearly. Put a header comment in files you own entirely. In shared files, such as a tsconfig that also has hand-written options, add comments that mark where the generated section begins and ends. A hand edit inside generated content will be overwritten on the next sync, so tell people explicitly not to make one.

#!/usr/bin/env bash
# ci.sh: run by any CI runner on every pull request.
# -e stops at the first failing command, so drift blocks everything after it.
set -euo pipefail

# Install exactly what the lockfile specifies.
npm ci

# Step 1: detect sync drift before spending time on builds.
# nx sync:check runs every registered sync generator in check mode.
# It exits non-zero if any generator would change a file, and the log
# names the stale config (e.g. a missing tsconfig project reference).
if ! npx nx sync:check; then
  echo "Workspace config is out of sync." >&2
  echo "Run 'npx nx sync' locally and commit the result." >&2
  exit 1
fi

# Step 2: only reached when config is consistent, so any failure here
# is a real code problem, not a stale project reference.
npx nx run-many -t lint test build

# One-time migration, run by hand before enabling this script:
#   git switch -c chore/initial-nx-sync
#   npx nx sync
#   git add -A && git commit -m "chore: apply nx sync generators"
# Open that commit as its own PR, with no feature changes mixed in.

A CI script that runs nx sync:check before lint, test and build so config drift fails early with an actionable message, plus the one-time commands for the initial sync PR.

When to avoid sync generators and the alternatives

Sync generators are not free, and some repos are better off without them.

Generated files are committed. Every sync writes to tracked files, so a new project produces changes in files nobody edited by hand. Shared files such as the root tsconfig.json attract merge conflicts when two branches each add a project. The fix is usually to resolve the conflict and rerun sync, but reviewers still pay for the noise.

Output must be deterministic. If a generator iterates an unordered collection or embeds timestamps, nx sync:check will report drift on clean branches and CI will fail intermittently. Sort keys and paths, and derive output only from the project graph and files on disk.

Latency compounds. A generator registered on a task runs before that task. A generator that reads many files or does heavy computation slows down every build or typecheck it is attached to.

Sync trusts the graph. If dependencies come from dynamic imports or implicit relationships that Nx does not detect, the generated config will be wrong. Fix the graph with explicit implicitDependencies first.

Alternatives

Approach Enforcement strength Upkeep Fit for small repos
Sync generator Strong; CI can block drift Code to maintain and test Often overkill
Lint rule Strong for code-level rules; cannot rewrite config Moderate Good
Review checklist Weak; depends on attention Low Good
One-off script None after it runs Low Good for migrations
Hand maintenance None Grows with project count Fine under a handful of projects

Impact on development cost and engineering time

A custom sync generator is code you own. You write it, add tests against a virtual file tree, and revisit it when Nx changes its devkit APIs or project graph shape during upgrades. That cost is small but permanent, so judge it against the manual work it replaces.

Illustrative estimate (assumed numbers, not a benchmark)

Assume:

  • R = 6 review comments per month about drifted config, at 15 minutes each (author fix plus re-review)
  • B = 2 broken type-check or build incidents per month, at 60 minutes each to diagnose
  • M = 8 hours per year to maintain the generator and its tests
  • W = 12 hours to write it initially

Annual time saved = 12 × (R × 0.25 + B × 1) = 12 × (1.5 + 2) = 42 hours

First-year net = 42 − (W + M) = 42 − 20 = 22 hours. Later years net 42 − 8 = 34 hours.

Replace these inputs with figures from your own review history. If R and B are near zero, the generator costs more than it saves.

What to convert first

Start with the files your team keeps correcting in code review. Search merged pull requests for repeated comments such as "add the reference," "missing path alias," or "update CODEOWNERS." Repetition shows that the config can be derived from the project graph and that people keep getting it wrong. Config that changes rarely, or that depends on human judgment rather than graph structure, should stay hand-maintained.

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 sync generators, 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.