What sync generators are and why derived config drifts

In a TypeScript monorepo, several files describe facts the project graph already knows. The references array in each library's tsconfig.json, path mappings in a root config, and per-project dependency lists in package.json all depend on which projects import which. When engineers maintain these by hand, they drift. Someone adds an import from orders-domain to billing-api and never adds the matching project reference. Someone deletes a library and leaves a stale path alias behind. Nothing fails right away. Later, a tsc --build run compiles in the wrong order, editors resolve types from source in one place and from stale declaration output in another, or a clean CI checkout fails on an error nobody can reproduce locally.

A sync generator removes the hand-editing. It is a generator that reads the workspace and the project graph, writes files so they match, and reports whether anything was out of sync. It is idempotent: on an aligned workspace it changes nothing, so it can run on every task without creating noise. A scaffolding generator is different. It runs once to create a library or component, and from then on its output belongs to you. A sync generator keeps owning its output for the life of the repository.

This tutorial treats that config as computed output and treats drift as a CI failure, in the same category as a broken test. The approach helps most when backend and platform teams run many interdependent libraries and dependency edges change every week. In a repository with a handful of stable packages, editing these files by hand may still be good enough.

 HAND-MAINTAINED (drifts)           SYNC GENERATOR (computed output)

+-------------------+               +----------------------------------+
| source imports    |               | workspace + project graph        |
| billing-api ->    |               | billing-api -> orders-domain     |
|   orders-domain   |               +----------------+-----------------+
+---------+---------+                                |
          | hand edits                               v
          | (or forgotten)          +----------------------------------+
          v                         | sync generator (idempotent)      |
+-------------------+               | read graph, write matching files |
| tsconfig refs     |               +--------+-----------------+-------+
| path aliases      |                        |                 |
| package.json deps |                        v                 v
+---------+---------+                 +--------------+  +--------------+
          |                           | tsconfig     |  | report:      |
          v                           | references   |  | in sync?     |
+-------------------+                 | path aliases |  +------+-------+
| wrong build order |                 | package deps |         |
| stale .d.ts types |                 +--------------+         |
| CI-only failures  |                           +----------------+--+
+-------------------+                           v                   v
                                      +----------------+  +----------------+
                                      | no change:     |  | drift found:   |
                                      | task proceeds  |  | CI fails       |
                                      +----------------+  +----------------+

Hand-maintained tsconfig references, path aliases and dependency lists fall behind the imports they describe, while a sync generator recomputes them from the project graph and reports drift so CI can fail on it.

How sync generators run: task-level and global registration

Nx can attach a sync generator to a single target or to the whole workspace. The scope decides when the generator runs and how much work it does.

Task-level registration

A target lists generators in its syncGenerators option, for example "syncGenerators": ["@nx/js:typescript-sync"] on a build or typecheck target. Before Nx runs that target, it runs the listed generators, so the config the task reads is current at the moment it is needed. Use this scope when the derived files feed a specific tool, such as tsc reading project references.

Global registration

Generators that maintain workspace-wide files, such as a root manifest or shared lint config, are registered under the sync section of nx.json, for example "sync": { "globalGenerators": [...] }. They are not attached to any task. They run when you call nx sync or nx sync:check.

Scope Where registered Runs when Use for
Task-level Target syncGenerators Before that task and its dependents Config a specific task reads
Global nx.json sync section nx sync / nx sync:check Workspace-wide files

The before-task check

When a task with sync generators is about to run, Nx compares the generated output against the files on disk. In an interactive terminal it prompts you to apply any changes. Setting applyChanges in the sync section of nx.json makes Nx apply them without asking.

CI should never write these files. A pipeline that silently fixes drift hides the uncommitted change and builds code that nobody reviewed. Run nx sync:check in CI so drift fails the build instead.

These keys have changed across releases, so confirm the exact config shape in the docs for your installed Nx version.

# Illustrative layout. Confirm key names against the docs for your installed Nx version.

# Task-level: run the TypeScript sync generator before this project's typecheck.
cat > packages/api/project.json <<'EOF'
{
  "name": "api",
  "targets": {
    "typecheck": {
      "executor": "nx:run-commands",
      "options": { "command": "tsc --build", "cwd": "packages/api" },
      "syncGenerators": ["@nx/js:typescript-sync"]
    }
  }
}
EOF

# Global: workspace-wide generators, not tied to a task.
# applyChanges: true lets local runs apply sync changes without prompting.
# '@myorg/workspace-plugin:root-manifest' is a placeholder for your own generator.
cat > nx.json <<'EOF'
{
  "sync": {
    "applyChanges": true,
    "globalGenerators": ["@myorg/workspace-plugin:root-manifest"]
  }
}
EOF

# Locally: apply all task-level and global sync generators.
npx nx sync

# In CI: check only. A non-zero exit fails the build when files are out of sync.
npx nx sync:check

A target-level syncGenerators entry, a global sync section in nx.json with applyChanges, and the split between nx sync locally and nx sync:check in CI.

Tutorial: keeping TypeScript project references aligned with the built-in sync generator

Start with a workspace that uses the TypeScript project-references setup, where the @nx/js plugin is registered and the @nx/js:typescript-sync generator is attached to type-checking and build tasks. Generate two libraries, utils and orders. Each gets a project-level tsconfig.json with references pointing at its own tsconfig.lib.json (and tsconfig.spec.json if tests were scaffolded). The root tsconfig.json has an empty files array and a references array listing both libraries. Neither library references the other yet.

Now import a function from utils inside orders, and declare utils as a dependency in orders's package.json so the package manager links it. Nx infers a dependency edge from orders to utils from that import, but orders/tsconfig.lib.json still has no reference to utils. tsc --build relies on references to decide build order and where to find declaration output, so it now has an incomplete picture. Run any task that has the sync generator attached and Nx reports the workspace as out of sync.

Run nx sync and inspect the diff with git diff. The references array in orders/tsconfig.lib.json gains a relative path to the utils project. The root references array changes only if a project was added or removed. The edit is safe because each library's tsconfig sets composite: true, which also enforces declaration output. Without composite, TypeScript refuses to treat the project as a reference target. Commit the change alongside the import so reviewers see both together.

# 1. orders now imports from utils (and lists it in package.json)
#    packages/orders/src/lib/orders.ts:
#      import { formatPrice } from '@acme/utils';

# 2. Check for drift without writing anything (exits non-zero if out of sync)
npx nx sync:check

# 3. Let the @nx/js:typescript-sync generator update the tsconfig files
npx nx sync

# 4. Review what changed
git diff -- '*.json'

# Expected shape of the diff in packages/orders/tsconfig.lib.json:
#   "compilerOptions": {
#     "composite": true,          <- required for a project to be a reference target
#     ...
#   },
# - "references": []
# + "references": [
# +   { "path": "../utils/tsconfig.lib.json" }
# + ]
#
# The exact reference target, the utils tsconfig.json or tsconfig.lib.json,
# depends on the workspace setup. Check the diff rather than assuming one.
#
# Root tsconfig.json is untouched: no project was added or removed,
# so its "files": [] and "references" to utils and orders stay as they were.

# 5. Confirm tsc --build now sees the dependency, then commit both together
npx tsc --build packages/orders/tsconfig.lib.json
git add packages/orders
git commit -m "orders: use formatPrice from utils"

Detecting and fixing tsconfig reference drift after orders starts importing utils, with the expected change to orders/tsconfig.lib.json and the unchanged root references shown as comments.

Gating drift in CI with nx sync:check

Locally, nx sync runs every registered sync generator and writes the results to disk. nx sync:check runs the same generators without writing anything and exits non-zero if any of them would change a file. Use the first on a developer machine and the second in CI.

Run the check before anything that depends on it

Put nx sync:check near the start of the pipeline, after dependency installation and before builds, type-checks, or affected-project calculations. Ordering matters because stale project references rarely fail with a clear message. A missing reference usually shows up as a confusing "cannot find module" or "file is not listed within the file list of project" error deep in a tsc run. If the sync check fails first, the log names the generator and the files that drifted, so the reviewer sees the cause instead of a symptom.

When the check fails

The fix belongs on the branch, not in CI. The developer runs nx sync, reviews the diff (usually references entries in one or more tsconfig.json files), and commits it with the change that caused it. Do not let CI auto-commit the fix. That hides the drift from review and creates commits nobody authored.

Diagnosing a check that never passes

If nx sync followed by nx sync:check still reports changes, two writers are disagreeing about the same file:

  • Overlapping generators. Two sync generators write the same file and each undoes the other's edits. Find every generator that touches the file and give each file a single owner.
  • Formatters or lint autofix. A formatter or autofix step rewrites the generator's output, for example by changing key order, quoting, or trailing commas. Either have the generator emit output in the formatter's style or exclude the generated files from that tool.
# .github/workflows/ci.yml
name: CI

on:
  pull_request:

jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # Full history so `nx affected` can compare against the base branch
          fetch-depth: 0

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

      - run: npm ci

      # Gate on drift first. This step only reads files and exits non-zero
      # if any sync generator would change them, so a stale tsconfig
      # reference fails here with the generator and file names, not later
      # as a confusing tsc error.
      - name: Check workspace is in sync
        run: npx nx sync:check

      # Builds and type-checks run only after the graph-derived files
      # (for example tsconfig references) are confirmed up to date.
      # If this job fails at the step above, the fix is local:
      #   npx nx sync && git add -A && git commit -m "chore: nx sync"
      - name: Build and type-check affected projects
        run: npx nx affected -t build typecheck --base=origin/main

A GitHub Actions job that runs nx sync:check right after install and before builds and type-checks, so tsconfig drift fails early with a direct message and the fix is a local nx sync and commit.

Writing and testing a custom sync generator

A custom sync generator is an ordinary Nx generator with a narrower contract. It reads the project graph, usually through createProjectGraphAsync from @nx/devkit. It computes the full desired contents of each file it manages and writes them through the Tree. Nx compares the resulting changes against disk, so a generator that writes identical bytes produces no diff and counts as in sync. When something does change, return a result with an outOfSyncMessage that names the trigger, for example "orders-api now depends on billing-client; deploy/orders-api/deps.json was regenerated." That message is what an engineer sees when nx sync:check fails, so it should explain the cause, not only the symptom.

Design rules

  • Deterministic output. Sort projects and keys explicitly. Leave out timestamps, absolute paths and anything else that depends on the host. Otherwise the check fails on every machine.
  • Idempotent. A second run over its own output must produce no changes.
  • Fast. The generator can run before tasks, so read from the graph instead of re-parsing source files.
  • Owned files only. Write files the generator fully owns. Avoid hand-edited files. Merging into shared config invites conflicts with people editing it.
  • Formatter-consistent. Call formatFiles(tree) before returning, or emit output already in the repo's style.

Testing

Use createTreeWithEmptyWorkspace from @nx/devkit/testing to build an in-memory workspace. Add fixture projects, and mock or construct the project graph they imply. Run the generator and assert the file contents and the out-of-sync message. Then run it again and assert that the tree reports no changes and no message is returned. That second assertion is the idempotence guarantee CI depends on.

When to avoid sync generators, and the alternatives

A sync generator is only as correct as its input. The built-in TypeScript generator derives references from the project graph, so anything the graph cannot see is missing from the output. That includes dynamic imports built from runtime strings, dependencies declared only through implicitDependencies, and imports resolved by custom bundler aliases. When those patterns are common, the generated references are incomplete, and they look trustworthy, which is worse than having none. Sync generators also need a recent Nx release. If upgrading is blocked, the feature is not available. They are a poor fit too when most config is hand-tuned, so regenerating it fights the humans who own it, or when nobody on the team will maintain custom generator code through Nx upgrades.

Alternative Strength Trade-off
Hand-maintained config with code review No tooling to own Drift is caught only when a reviewer notices it
One-off scaffolding generator Correct at creation Says nothing about changes made later
Custom lint rule that detects drift Flags drift in the editor and in CI Engineers still fix every violation by hand
Standalone script run in CI Works on any Nx version You maintain your own graph-reading logic
Path mappings without project references One paths block, simple to reason about You lose incremental tsc --build and per-project type boundaries

Detection-only options suit teams that want a signal but not automated writes. Path mappings suit repos where type-check time is not yet a bottleneck.

Impact on cost and engineering time

Sync generators trade a small, recurring runtime cost for less manual config maintenance. Whether that trade pays off depends on how often your graph changes and how much time your team currently spends on reference drift.

Cost Saving
Added latency before every affected task run No hand-maintained references arrays
Large one-time adoption diff that can expose circular dependencies or misconfigured composite settings Drift fails early, in CI, with a clear message
Surprise diffs when changes are auto-applied Fewer confusing type-check errors caused by missing references
Hand edits to generated files get overwritten Consistent config across all projects
Custom generators need an owner through Nx upgrades New projects are wired correctly by default

Illustrative estimate (assumed numbers, not a benchmark): weekly cost = runs × sync overhead, compared against weekly saving = drift incidents × debug time.

Assume 40 engineers each make 30 task runs a week with 1 second of sync overhead. The cost is 40 × 30 × 1 s = 1,200 s, or 20 minutes. Now assume 4 drift incidents a week at 45 minutes each. The saving is 180 minutes. Plug in your own numbers. If your graph rarely changes, the balance can flip.

Practices

  • Mark generator-owned files and fields clearly. Document which files and fields the generator owns so engineers don't hand-edit them and lose the changes.
  • Choose a less aggressive mode locally if needed. If auto-applied diffs surprise people, configure Nx to prompt or only check locally, and keep nx sync:check as the gate in CI.
  • Pin the Nx version. Sync behaviour and configuration options can change between releases, so verify them against the documentation for your pinned version before you upgrade.
  • Assign a named owner. Each custom generator needs someone responsible for keeping it working, so that upgrades don't leave it broken and unmaintained.

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.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.