What Project Crystal changes and how inferred targets merge with project.json

Before Project Crystal, every target lived in project.json and named an executor such as @nx/vite:build or @nx/jest:jest. The executor wrapped the tool, and you had to declare inputs, outputs and cache yourself, often duplicating details already present in the tool's own config file.

With inferred targets, plugins listed in the plugins array of nx.json scan the workspace for files they recognize, such as vite.config.ts, jest.config.ts, an ESLint config or a webpack config. For each match, the plugin creates targets that call the underlying tool directly. It also derives inputs, outputs and cache settings from that config. For example, the build output directory comes from the Vite config rather than a hand-maintained outputs entry. The tool config becomes the single source of truth, which removes a common source of stale cache results.

Inference does not take away your control. Nx resolves each target in layers:

Plugin inference → targetDefaults in nx.json → target in project.json (or package.json) → resolved task configuration

Each layer can override or extend the one before it. If two plugins infer the same target name, the plugin listed later in the array takes precedence. Explicit project configuration wins last. When a project target uses the same executor or command as the inferred one, its options merge into the inferred options. When it specifies a different executor or command, it replaces the inferred target rather than merging with it.

Because of this layering, project.json alone no longer tells you what will run. Use nx show project <name> to inspect the fully resolved result.

 Tool config files found in each project
 +-------------+ +-------------+ +-------------+ +-------------+
 | vite.config | | jest.config | | ESLint cfg  | | webpack cfg |
 +------+------+ +------+------+ +------+------+ +------+------+
        |               |               |               |
        v               v               v               v
 +-----------------------------------------------------------+
 | 1. Plugin inference (plugins array in nx.json)            |
 |    targets, inputs, outputs, cache read from tool config  |
 |    same target in two plugins: later plugin wins          |
 +-----------------------------+-----------------------------+
                               | override / extend
                               v
 +-----------------------------------------------------------+
 | 2. targetDefaults in nx.json                              |
 +-----------------------------+-----------------------------+
                               | override / extend
                               v
 +-----------------------------------------------------------+
 | 3. Target in project.json (or package.json)               |
 |    same executor/command -> options merged                |
 |    other executor/command -> inferred target replaced     |
 +-----------------------------+-----------------------------+
                               |
                               v
 +-----------------------------------------------------------+
 | Resolved task configuration                               |
 | Inspect with: nx show project <name>                      |
 +-----------------------------------------------------------+

Nx builds each target in layers: plugins infer it from tool config files, targetDefaults in nx.json override or extend it, and the project.json (or package.json) target applies last to produce the resolved task configuration.

Who should migrate, when to do it, and when to hold off

The main reason to adopt inference is that the tool's own config file becomes the single source of truth. Under the executor model, a Vite or Jest project carried options in project.json that partly duplicated vite.config.ts or jest.config.ts, and the two drifted apart. With inferred targets, the plugin reads the tool config directly. Cache inputs and outputs then come from what the tool actually reads and writes. You no longer have to maintain hand-written outputs arrays that someone forgot to update.

Who benefits most

Platform teams running workspaces with many similar projects on official plugins see the largest payoff. Fifty libraries with near-identical build and test targets collapse to almost no per-project config. A change to defaults then happens once, in nx.json.

When to migrate

  • Before a large Nx upgrade, so the upgrade doesn't also have to carry executor-to-inference changes.
  • When executor options and tool configs have visibly drifted, since the migration forces you to reconcile them.

When to hold off

  • Your workspace leans heavily on custom executors. The conversion generators won't touch them.
  • Executor options have no equivalent in the tool's config, so the conversion would have to drop behavior or approximate it.
  • You don't have time to verify equivalence as described in Step 3.
  • CI scripts, dashboards or scripts outside Nx call targets by name, and a converted target may be renamed.

Expect that some projects will never reach a fully inferred end state. A hybrid of inferred targets plus a few explicit overrides is a legitimate place to stop.

Step 1: Capture a baseline of every resolved target

Before touching any configuration, record what Nx currently believes about every project. The goal is a snapshot of the resolved targets: the result after plugins, targetDefaults and project.json have been merged. Diffing raw project.json files tells you little once inference is involved, because the file can shrink while the effective target stays identical, or stay the same while a plugin quietly changes an input.

Start from a clean branch off your main line, and run nx reset first so a stale daemon or cached graph does not leak into the baseline. Then dump one file per project. A short loop over the output of nx show projects works: for each name, run nx show project <name> --json and redirect it to something like baseline/projects/<name>.json. One file per project keeps later diffs readable and lets you review a single project's conversion in isolation instead of scrolling through a monolithic dump.

Next to those files, save the whole workspace graph with nx graph --file=baseline/graph.json. Per-project output shows targets, executors, options, inputs, outputs and cache settings. The graph adds what per-project files miss: dependency edges between projects, which a plugin change can alter without any single target looking different.

Commit the baseline/ directory as its own commit with no other changes. That commit becomes the fixed reference point for every equivalence check in the next steps. If the JSON contains absolute paths or machine-specific values, note them now so you can normalize them before comparing.

#!/usr/bin/env bash
# Capture the fully resolved Nx configuration as a baseline before any migration work.
set -euo pipefail

# Work on a dedicated branch so the baseline commit stays isolated.
git checkout -b nx-inferred-targets-baseline

# Clear the daemon and cached project graph so stale state cannot leak in.
npx nx reset

mkdir -p baseline/projects

# One JSON file per project: resolved targets after plugins,
# targetDefaults and project.json have been merged.
npx nx show projects | while IFS= read -r project; do
  # Scoped names like @acme/ui contain '/', so make them filename-safe.
  safe_name="${project//\//__}"
  npx nx show project "$project" --json > "baseline/projects/${safe_name}.json"
  echo "captured ${project}"
done

# Whole-workspace graph: dependency edges that per-project files do not show.
npx nx graph --file=baseline/graph.json

# Flag absolute paths early so they can be normalized before diffing.
grep -rl "$PWD" baseline/ || echo "no absolute workspace paths found"

# Commit the baseline on its own, with no other changes.
git add baseline/
git commit -m "chore: capture resolved Nx target baseline before migration"

A shell script that resets Nx, writes one resolved-configuration JSON file per project plus the workspace graph into baseline/, checks for absolute paths, and commits the result as a standalone reference commit.

Step 2: Run convert-to-inferred one plugin and one project at a time

Pick one official plugin, such as Jest, and one leaf library: a project nothing else depends on, with a short target list and fast tests. If the conversion goes wrong there, no downstream project is affected. Run that plugin's convert-to-inferred generator with its project option set to the library, instead of letting it sweep the whole workspace.

Treat the output as a proposal and review the diff. In nx.json, look for the new plugin entry and its target-name options. Inferred targets get their names from those options, so a mismatch silently breaks dependsOn chains and CI scripts that call test or build. In project.json, the converted target should shrink, but it often won't disappear completely. That remainder is usually deliberate. An option the tool's own config file can express, like a coverage directory, belongs in that file. An option the tool config can't express stays in project.json as an explicit override, which Nx merges on top of the inferred target. Don't remove these overrides to make the file look tidy. Each one is behavior your baseline depends on.

Next, stop the plugin from inferring targets for projects that haven't been converted yet. Unconverted projects would otherwise end up with an inferred target sitting alongside their legacy executor. Use the plugin entry's include (or exclude) globs to limit inference to converted projects. The generator may already add these when it runs scoped to one project, so check the diff before you add them yourself. Widen the globs as more projects are converted.

Commit each plugin's conversion separately. If one step needs to be undone, git revert then removes that change cleanly without touching the others.

# Convert ONE leaf library with ONE plugin's generator.
# Start from a clean tree so the diff contains only this change.
git status --porcelain   # expect no output

# Scope the generator to a single project instead of the whole workspace.
npx nx g @nx/jest:convert-to-inferred --project=date-utils

# Review the proposal before trusting it.
# nx.json: check the new @nx/jest plugin entry, its targetName option
# (must still be "test" if dependsOn chains or CI call `nx test`),
# and whether include/exclude globs were added to limit inference
# to converted projects.
git diff -- nx.json

# project.json: the test target should shrink. Any options left behind
# are overrides the Jest config can't express. Keep them.
git diff -- libs/date-utils/project.json

# Any option moved into the tool's own config shows up here.
git diff -- libs/date-utils/jest.config.ts

# Inspect the merged result: inferred target plus remaining overrides.
npx nx show project date-utils --json

# Confirm an unconverted project did not gain a duplicate inferred target.
npx nx show project billing-api --json

# Re-run against the baseline captured in Step 1, bypassing the cache.
npx nx test date-utils --skip-nx-cache

# One plugin, one commit, so `git revert <sha>` undoes only this step.
git add nx.json libs/date-utils
git commit -m "chore(nx): convert date-utils test target to @nx/jest inferred"

Run @nx/jest:convert-to-inferred on one leaf library, review the nx.json, project.json and Jest config diffs, confirm an unconverted project has no duplicate inferred target, re-test, and commit the change on its own.

Step 3: Prove equivalence of config, cache behavior and outputs

After each conversion commit, run nx show project <name> --json again and diff the output against the baseline you captured. Sort keys before diffing so that reordering does not hide real changes. Pay close attention to four fields: inputs, outputs, dependsOn and cache. A missing ^production input or a dropped dependsOn entry still produces a passing build. The damage shows up later as stale artifacts.

Next, check the target names. Plugin options such as buildTargetName decide what an inferred target is called. If the plugin's default differs from your old name, nx affected -t build or a CI script that calls a specific target can silently match nothing. Search your pipeline definitions for every target name before you merge.

To verify caching, follow these steps:

  1. Run the target.
  2. Run it again and confirm that Nx reports a cache hit.
  3. Edit one file that should count as an input.
  4. Run it a third time and confirm it executes for real.

If step 2 misses, your inputs probably include something volatile. If step 4 hits, an input is missing.

Finally, build the project both before and after the conversion and compare recursive file listings of the output directory. Deploy steps, Dockerfiles and artifact uploads usually hardcode these paths. A move from dist/apps/api to apps/api/dist breaks them even when the build itself passes.

What to compare Baseline source Post-migration source
Command and options executor + options in project.json Plugin-inferred command/options
inputs / namedInputs project.json, nx.json targetDefaults Plugin inference merged with nx.json
outputs project.json outputs Plugin-derived from tool config
dependsOn project.json or targetDefaults targetDefaults or plugin
cache flag targetDefaults / project.json Plugin default
Target name project.json key Plugin option, e.g. buildTargetName
Output file listing Pre-migration build Post-migration build
#!/usr/bin/env bash
# Compare a migrated target against the baseline captured before conversion.
set -euo pipefail
PROJECT=api
TARGET=build
BASE=.migration-baseline   # created in Step 1 from the pre-migration commit

# 1. Config diff: sort keys (-S) so reordering does not show up as a change.
nx show project "$PROJECT" --json \
  | jq -S --arg t "$TARGET" '.targets[$t] // error("target \($t) missing: check buildTargetName")
      | {executor, command, options, inputs, outputs, dependsOn, cache}' \
  > "$BASE/after-$TARGET.json"
diff -u "$BASE/before-$TARGET.json" "$BASE/after-$TARGET.json" || echo "Review the config diff above"

# 2. Cache check. Inspect the Nx summary line; exact wording varies by version.
nx run "$PROJECT:$TARGET"                      # run 1: populate the cache
nx run "$PROJECT:$TARGET" | tee /tmp/run2.log   # run 2: expect a cache hit
grep -qi 'cache' /tmp/run2.log || echo "WARN: no cache hit, look for volatile inputs"

echo '// cache-bust' >> "apps/$PROJECT/src/main.ts"  # touch a file that should be an input
nx run "$PROJECT:$TARGET" | tee /tmp/run3.log   # run 3: expect a real execution
git checkout -- "apps/$PROJECT/src/main.ts"     # restore the input file

# 3. Output layout: compare recursive file listings so deploy paths still match.
OUT_BEFORE=dist/apps/$PROJECT   # old executor output path
OUT_AFTER=apps/$PROJECT/dist    # read the new path from the target's outputs
( cd "$OUT_AFTER" && find . -type f | sort ) > "$BASE/files-after.txt"
diff -u "$BASE/files-before.txt" "$BASE/files-after.txt" \
  || echo "Output files changed: update Dockerfiles and artifact uploads"
[ "$OUT_BEFORE" = "$OUT_AFTER" ] || echo "Output dir moved: $OUT_BEFORE -> $OUT_AFTER"

# 4. Find CI references to target names that may no longer exist.
grep -rnE "(-t|--target[s]?)[ =]|nx run" .github/ ci/ 2>/dev/null || true

A bash script that diffs the sorted target config against the baseline, runs the three-step cache hit/miss check, compares output file listings, and finds CI references to target names that may have changed.

Alternatives for custom and community executors

Once the converters have run, list what is left. Search every project.json for remaining executor fields, or run nx show project <name> --json and filter for targets that still declare one. What remains is usually in-house executors, community packages with no plugin, and targets whose options the converter would not translate. Decide each one on purpose. Not every target needs to be inferred.

Option Effort Visibility Caching control Maintenance burden
Keep as explicit target None Fully visible in project.json Explicit inputs/outputs per target Executor code and its Nx compatibility stay yours
Replace with nx:run-commands Low per target Command is readable inline You must declare inputs and outputs by hand, or caching is wrong Duplicated config if many projects share it
Local plugin using the createNodes API Moderate, once Hidden until nx show project Centralized and consistent You own the plugin across Nx upgrades

Keep a target explicit when only one or two projects use it. A plugin would add code without saving any configuration. Choose a local plugin when a tool's config file already exists in many projects. That is the case inference was built for.

Roll out to a few services first. In CI, run both nx affected and a periodic full nx run-many. Affected runs catch breakage in changed projects. Full-graph runs catch drift in projects nobody touched. Before you merge, search deploy scripts, Dockerfiles and pipeline definitions for hardcoded target names and executor-specific flags. Inferred targets may be renamed, and flags now pass straight to the underlying tool.

Impact on development cost and engineering time

That search is a one-time cost. The ongoing costs and savings are different, and they fall on different people.

Cost Why it happens Offsetting saving
Harder onboarding and review Target options now come from plugin defaults and tool config files, so a diff to vite.config or jest.config can change a task without touching project.json. Reviewers must check nx show project <name> or the graph UI. Much less per-project configuration to maintain.
Unknown graph-computation cost Plugins read tool config files while building the project graph. Measure it in your own repo before and after. Do not assume it gets faster or slower.
Fragile mixed states Executor and inferred definitions can both apply, and their merged result is hard to predict. Fewer drifted executor options once the conversion is complete.
Costly rollback One commit that bundles several plugins or projects cannot be partially reverted. Small commits keep each revert cheap.
Tool upgrades need re-verification A new tool release can change what Nx infers. The baseline-and-diff script from Step 3 can be reused for every future upgrade.

Illustrative estimate (assumed numbers, not a benchmark). Net hours per year = (P × E × M ÷ 60) − (N × R) − (U × V)

The inputs are assumed as follows:

  • P = 40 projects
  • E = 6 config edits per project per year
  • M = 10 minutes saved per edit
  • N = 8 engineers
  • R = 2 hours of extra ramp-up and review for each engineer
  • U = 4 tool upgrades per year
  • V = 1 hour to re-verify each upgrade

That gives 40 − 16 − 4 = 20 hours saved. If your repo has fewer projects or more frequent upgrades, the result can easily be negative, so plug in your own numbers before you commit.

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 executor migration inferred targets, 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.