What Project Crystal changes and why teams adopt it
Project Crystal is the name for Nx's shift from hand-written targets to inferred targets. Instead of declaring every build, test and lint target in project.json with an executor and options, you register Nx plugins in the plugins array of nx.json. Each plugin scans the workspace for the files it understands, such as a Vite config, a Jest config or an ESLint config, and creates targets for any project that contains one. Scripts in a project's package.json also become runnable targets. Plugins can derive caching metadata from the tool's own settings. For example, a plugin can read a build output directory from the bundler config, so you don't have to restate it in an outputs array.
Why the source of truth matters
With explicit executors, the same facts live in two places. The tool's config file holds them, and so does a project.json block that wraps the tool. When someone changes an output path or adds a config file in one place and forgets the other, cache entries go stale or affected misses a project. Inference removes that duplication. The tool config defines the behavior, and Nx reads it.
Who benefits, and when to start
The biggest gains go to platform engineers who maintain dozens of similar services or libraries. In those workspaces, project.json files are mostly copy-pasted boilerplate that drifts over time. Start the migration when that drift is already costing you, for example through inconsistent cache settings, review noise in target definitions, or slow onboarding for new projects. If you have a small workspace with a few heavily customized targets, the payoff is smaller.
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"production": ["default", "!{projectRoot}/**/*.spec.ts"]
},
"plugins": [
{
"plugin": "@nx/vite/plugin",
"options": {
"buildTargetName": "build",
"serveTargetName": "serve",
"testTargetName": "test"
}
},
{
"plugin": "@nx/eslint/plugin",
"options": {
"targetName": "lint"
}
}
]
}
An nx.json that registers the Vite and ESLint plugins so any project containing a vite.config.ts or ESLint config gets build, serve, test and lint targets inferred from those files, with no executor blocks in project.json; run nx show project <name> to see the targets Nx resolved.
How inference, targetDefaults and project.json merge
Nx builds each project's configuration in layers, and later layers win. First, every plugin registered in nx.json scans the workspace for files it recognizes, such as a Vite, Jest or ESLint config, and creates targets from them. Next, targetDefaults in nx.json apply workspace-wide settings to targets that match by name or executor. Finally, whatever is in a project's own project.json (or the nx key in package.json) is merged on top. In practice, an explicit project.json entry overrides the matching inferred value.
This order is what makes a gradual migration safe. Adding a plugin does not discard your existing definitions. If project.json already declares a build target, your explicit options, inputs and outputs still take precedence over whatever the plugin infers. You can enable inference, confirm that nothing changed, and then delete explicit settings one target or one project at a time. Each deletion exposes only the inferred value for that setting, so the scope of any change stays small and easy to review.
To see the final merged configuration, run nx show project <name>. Add --web to open an interactive view that shows each target's resolved options, inputs and outputs, along with the file that contributed each setting: a plugin, nx.json, or project.json. Use this view when a setting is not what you expected.
One operational change follows from this. Computing the project graph now runs plugin code, so it costs more than reading static JSON. The Nx daemon and the cached project graph absorb that cost. If you suspect stale results after changing plugin configuration or tool configs, run nx reset to clear the cache and stop the daemon.
# 1. Snapshot the merged config before enabling inference.
# --json prints the fully resolved project configuration.
nx show project my-app --json > /tmp/my-app.before.json
# 2. Register the plugin in nx.json (e.g. add "@nx/vite/plugin" to "plugins"),
# leaving project.json untouched. Explicit targets there still win.
# 3. Clear the cached graph and stop the daemon so the new plugin
# is actually executed when the graph is recomputed.
nx reset
# 4. Snapshot again and compare. Because project.json is applied last,
# existing targets should be unchanged; new inferred targets may appear.
nx show project my-app --json > /tmp/my-app.after.json
diff /tmp/my-app.before.json /tmp/my-app.after.json
# 5. Delete one explicit setting from project.json (say, build.outputs),
# then inspect only that target to see the inferred value it now falls back to.
nx show project my-app --json | jq '.targets.build'
# 6. Open the interactive view to see which file contributed each
# option, input and output: the plugin, nx.json, or project.json.
nx show project my-app --web
A per-project migration loop that snapshots the merged configuration with nx show project, resets the daemon and graph cache after a plugin change, diffs the result, and then removes explicit settings one at a time.
Step-by-step migration workflow
Start with one plugin that covers a well-understood tool, such as Jest or Vite, rather than switching everything at once. Register it in the plugins array of nx.json. Doing this first adds inferred targets alongside your explicit ones without removing anything, so the workspace keeps behaving as before while you compare the two.
Configure the plugin's options so the inferred target names match the names your CI scripts and developers already use. Most official plugins expose options such as buildTargetName or testTargetName. If your pipelines call nx affected -t test, the inferred target must also be called test, or the migration will silently drop work from CI.
Use the plugin entry's include and exclude glob patterns to limit inference to the projects you are migrating now. This keeps the change small enough to review, and it keeps unusual projects out of scope until you are ready for them.
For each project in scope, run nx show project <name> --json and compare the inferred target with the explicit one. Check the command, options, inputs, outputs, and dependsOn. When the inferred target is equivalent, delete the redundant block from project.json, run the target, and commit. Committing one project at a time means any regression can be traced to a single revertible diff.
Some plugins ship migration or conversion generators that rewrite project.json for you. Look up the exact generator names in the docs for your installed version. Treat their output as a proposed diff that you review line by line, not as a finished migration.
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"plugins": [
{
"plugin": "@nx/vite/plugin",
"options": {
"buildTargetName": "build",
"testTargetName": "test",
"serveTargetName": "serve",
"previewTargetName": "preview"
},
"include": [
"apps/web-dashboard/**/*",
"libs/ui-components/**/*"
],
"exclude": [
"libs/legacy-charts/**/*"
]
}
]
}
An nx.json entry that registers only the Vite plugin, keeps the existing build, test, serve and preview target names, and limits inference to the two projects being migrated.
Verifying parity: inputs, outputs, caching and affected
Before you merge, capture the resolved configuration of every migrated project on the old branch and again on the new one. nx show project <name> --json prints the final merged targets, so you can diff the two snapshots directly instead of reasoning about how inference, targetDefaults and project.json combine.
Parity checklist
| Check | How to verify | Failure it catches |
|---|---|---|
inputs |
Diff resolved target config | Missing env vars, config files or named inputs |
outputs |
Run a clean build, list files on disk | Artifacts written outside cached paths |
dependsOn |
Diff resolved config, inspect nx graph |
Builds running before upstream libraries |
| Cache behaviour | Run the target twice, then change a source file | Hits that should be misses, and the reverse |
| Affected set | Compare nx show projects --affected on both branches with the same base |
Projects dropped from or added to CI runs |
Pay the most attention to inputs and outputs. Inference reads tool config files such as a Vite, Jest or ESLint config. If a config is built dynamically, wrapped in a helper, or uses a non-default layout, the plugin may not be able to evaluate it. When that happens it often still registers the target with fallback values and no warning. The target looks correct in the graph but has the wrong inputs or outputs.
The two errors fail in opposite directions. Inputs that are too narrow produce stale cache hits: Nx replays old artifacts after a change it never hashed, which is the dangerous case. Inputs that are too broad, or outputs that don't match where files actually land, produce unnecessary cache misses. These are safe but slow, and they quietly erase your caching gains.
#!/usr/bin/env bash
# Capture resolved Nx config, outputs and affected sets on both branches, then diff.
set -euo pipefail
PROJECTS="web-app ui-lib" # migrated projects to compare
PROBE_FILE="libs/ui-lib/src/index.ts" # same simulated change on both branches
snapshot() {
local branch=$1 label=$2 out="parity/$2"
git checkout -q "$branch"
mkdir -p "$out"
npx nx reset # start each side from an empty cache
for p in $PROJECTS; do
# Keep only the fields that drive hashing, caching and task ordering.
npx nx show project "$p" --json \
| jq -S '.targets | map_values({inputs, outputs, dependsOn})' \
> "$out/$p.targets.json"
done
# Clean build, then record exactly which files landed on disk.
rm -rf dist
npx nx run-many -t build -p ${PROJECTS// /,}
find dist -type f | sort > "$out/files-on-disk.txt"
# Same changed file on both sides, so any difference comes from the graph.
npx nx show projects --affected --files="$PROBE_FILE" | sort > "$out/affected.txt"
}
snapshot main old
snapshot chore/inferred-targets new
# Any line here is a parity gap to explain before merging.
diff -ru parity/old parity/new && echo "No differences in targets, outputs or affected set"
# Cache check on the new branch: the second run should be served from cache,
# and a run after editing a source file should miss.
npx nx run web-app:build
npx nx run web-app:build # expect a cache hit
echo "// parity probe" >> "$PROBE_FILE"
npx nx run web-app:build # expect a miss; a hit means inputs are too narrow
git checkout -- "$PROBE_FILE"
A script that snapshots the resolved inputs, outputs and dependsOn, the built files and the affected set on the old and new branches, diffs them, and then runs a repeat build and a post-edit build to check that the cache hits and misses when it should.
What to keep explicit in project.json, and when to avoid inference
Inference only covers what a plugin can read from tool configuration. Some things are not tool configuration, and they belong in project.json permanently:
tags. These encode ownership and architectural boundaries used by module-boundary lint rules. No build tool config can supply them.implicitDependencies. These exist because the dependency is invisible to source analysis, such as a generated client or a shared fixture directory. Inference cannot recover what the graph cannot see.- Custom or community executors with no matching plugin. Without a plugin that recognizes the tool, nothing gets inferred. The explicit target is the only definition.
- Targets whose options deliberately differ from the inferred ones. Examples include a non-default output path, an extra input, or a stricter flag in CI. Keep the override and record the reason next to it. Otherwise someone will later "clean it up" back to the inferred defaults.
Delay migration in a few situations:
- Tool configs are heavily dynamic. If a config branches on environment variables or computes paths at runtime, a plugin may read it differently from how the tool resolves it during the actual run.
- The team reads
project.jsonto understand a project. Inference moves that knowledge into plugin code.nx show project <name>recovers it, but only if people actually adopt the command. - The only path forward is a local plugin. A plugin is code you own, test and update whenever the tool's config format changes. Weigh that ongoing cost against a handful of explicit targets that rarely change.
A mixed state is normal and acceptable, provided it is deliberate. Keep a short migration note in the repo. For each project still on explicit targets, the note should list the reason, an owner and the condition that would allow removal. Without that record, explicit targets that no longer serve a purpose stay in the repo indefinitely.
Alternatives: explicit targets, targetDefaults, local plugins
Inference is one of four ways to configure targets, and each one puts configuration in a different place.
| Approach | Visibility | Maintenance | Upgrade risk | Distance from Nx defaults |
|---|---|---|---|---|
Fully explicit project.json |
Every target readable in one file | High: changes repeat across projects | Low: nothing changes implicitly | Far |
targetDefaults only |
Shared config in nx.json, targets still declared per project |
Medium | Low | Moderate |
| Official plugins with inference | Requires nx show project to see the result |
Low | Higher: plugin updates can change inferred inputs or options | Close |
| Local plugin | Logic lives in repo code your team owns | Medium: you maintain the inference code | Medium: depends on Nx plugin APIs | Close in structure, custom in content |
Staying explicit fits repos with few projects or unusual build steps. targetDefaults alone removes most duplication without hiding where targets come from, so it suits teams that want centralization but distrust inference. Official plugins reduce config the most, and they let Nx changes reach you through upgrades. You should read the migration notes for each upgrade. A local plugin makes sense when in-house tooling, such as a custom codegen step, follows a consistent file convention that Nx can detect.
Target names need a decision before you switch. If you rename targets to match plugin defaults, CI scripts, editor tasks and developer habits that call the old names will break. If you configure the plugin to keep the old names, nothing breaks today, but the repo moves away from defaults. Documentation and generated examples will then stop matching your workspace.
Impact on CI, remote caching, cost and engineering time
Cache keys usually change after migration, and that is expected. Hand-written inputs tend to be broad, such as "everything in the project plus the root config." Plugins compute inputs from the tool's own configuration, so they often include or exclude different files. Different inputs produce different hashes. The first run of every migrated target will miss both local and remote cache. Plan one cold-cache run per migrated target, and schedule it so the misses don't land on everyone's pull requests at once.
Illustrative estimate (assumed numbers, not a benchmark)
- One-time CI cost = migrated targets × average cold run time
- Assume 40 targets × 3 min = 120 runner-minutes, paid once.
- Ongoing savings = config edits per month × time saved per edit
- Assume 6 edits × 15 min = 90 engineer-minutes per month, because you no longer update duplicated target definitions across projects.
- Ongoing cost = Nx upgrades per year × review time per upgrade
- Assume 4 × 2 h = 8 engineer-hours per year.
With these assumptions, the cold-cache bill is small next to engineering time. The upgrade review is the cost that keeps recurring. Plugin behavior, inferred target names and option names can change between versions. Treat every Nx upgrade as a change to your build definition. Diff the resolved project graph and target configuration before and after the upgrade, and review that diff like any other code change.
Measure graph calculation time in your own repo, both before and after migration, with the daemon on and off. Inference runs plugin code over your config files, so the result depends on your plugin set and repo layout. Don't assume it gets faster or slower.
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 inferred targets migration, 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
.envby 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.