What are Nx inferred tasks and what does Project Crystal change?

Inferred tasks are targets that Nx creates by reading the configuration files your tools already use. You register Nx plugins in nx.json. Each plugin scans the workspace for the files it understands, such as jest.config.ts, vite.config.ts, webpack.config.js, ESLint configs or tsconfig files, and adds matching targets to each project that contains them. A project with a Jest config gets a test target, and a project with a webpack config gets build and serve. You write nothing in project.json for any of them.

Project Crystal is Nx's name for this shift. In the older model, every target lived in project.json as an executor plus options, and you declared inputs, outputs and dependsOn by hand. The tool's own config file still existed, so the same facts were recorded in two places and could drift apart.

Aspect Executor-based project.json Inferred tasks
Where targets come from Declared per project Derived from tool config files
Inputs and outputs Written and maintained by you Defaults computed by the plugin
New project setup Copy target boilerplate Add the tool config, and targets appear
Source of truth project.json and the tool config The tool config

Teams adopt inferred tasks for three reasons. Duplicated config disappears. Cache inputs and outputs follow the plugin's understanding of the tool, so an upgrade to that understanding reaches every project at once. New services get working targets as soon as their tool config exists.

The biggest gains go to backend and platform engineers who maintain dozens of near-identical services. For those teams, keeping per-project target definitions aligned is ongoing, error-prone work.

How does target inference work, and which configuration wins?

Inference starts in the plugins array of nx.json. Each entry is either a plugin name as a string or an object with a plugin key and an options object. A typical backend registration lists @nx/jest/plugin with options of { "targetName": "test" }, and @nx/eslint/plugin with { "targetName": "lint" }. Those options control what the inferred targets are called. That matters when CI scripts and developer habits already depend on names like test or lint.

Plugins do not run at task execution time. When Nx computes the project graph, each plugin searches the workspace for the config files it understands, such as jest.config.ts or an ESLint config. For each match, it emits a target with a command, inputs, outputs, and cache settings derived from that file. Nx caches the result and recomputes it when the matched files change. This is why deleting a tool's config file removes the target: no file, no inference.

Nx builds each target by merging layers, with later layers overriding earlier ones:

  1. Plugin-inferred configuration. If two plugins infer the same target name, the plugin listed later in the array wins.
  2. targetDefaults in nx.json. These apply workspace-wide, keyed by target name or executor.
  3. Targets in project.json (or package.json). These are the most specific layer, so they win.

In practice, use targetDefaults to correct inference everywhere, and use project.json only for a genuine one-off.

Option names, defaults, and merge details have changed across Nx and plugin releases. Check the documentation for the versions installed in your workspace.

{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "namedInputs": {
    "default": ["{projectRoot}/**/*"],
    "production": ["default", "!{projectRoot}/**/*.spec.ts"]
  },
  "plugins": [
    {
      "plugin": "@nx/jest/plugin",
      "options": { "targetName": "test" }
    },
    {
      "plugin": "@nx/eslint/plugin",
      "options": { "targetName": "lint" }
    }
  ],
  "targetDefaults": {
    "test": {
      "inputs": ["default", "^production"]
    }
  }
}

An nx.json that registers the Jest and ESLint plugins to infer test and lint targets, then uses targetDefaults to adjust the inputs of every inferred test target; option names may differ in your installed version.

How do you check what Nx inferred before trusting it?

Because inferred targets don't appear in any file you can open, you need a habit of asking Nx what it resolved. The main tool is nx show project <name>. It prints the fully merged configuration for one project: every target, the executor or command it runs, its inputs, outputs, dependsOn, and whether it is cacheable. Add --json for machine-readable output, or --web to open the same data in the browser. The browser view lets you expand each target and see which plugin or file contributed it. The project graph (nx graph) shows the same per-project details alongside dependency edges. That makes it useful for confirming that dependsOn chains, such as build waiting on upstream libraries, still point where you expect.

Capture a baseline, then diff

Before you touch anything, run nx show project <name> --json against the current project.json and save the output outside the workspace or in a scratch branch. This baseline is the contract your migration must preserve. After you remove or slim down the project.json targets and let the plugins infer them, run the same command again and diff the two files. Focus on inputs and outputs first. A missing output path means Nx caches a run but never restores its artifacts. A missing input means the cache can serve a stale result. Both fail silently.

Make this step part of onboarding. Engineers new to the repo will look for a build target in project.json and find nothing. They should learn that nx show project is the source of truth now.

#!/usr/bin/env bash
# Snapshot the resolved targets for one project before and after the
# Project Crystal migration, then diff them. Requires jq.
set -euo pipefail

PROJECT=api              # placeholder project name
OUT=/tmp/nx-baseline     # outside the workspace so it survives branch switches
mkdir -p "$OUT"

# Keep only the fields that affect behavior and caching. -S sorts keys so
# the diff shows real changes, not ordering noise.
snapshot() {
  npx nx show project "$PROJECT" --json \
    | jq -S '.targets | map_values({
        executor,
        command: .options.command,
        inputs,
        outputs,
        dependsOn,
        cache
      })' > "$1"
}

# 1. Baseline from the existing project.json targets.
snapshot "$OUT/before.json"

# 2. Remove or slim the project.json targets so plugins infer them, then
#    run the script again from here, or continue manually:
read -rp "Edit project.json now, then press Enter to continue... "

# Clear the cached project graph so Nx recomputes the inferred targets.
npx nx reset
snapshot "$OUT/after.json"

# 3. Review inputs and outputs first. A missing output means artifacts are
#    never restored from cache. A missing input means stale cache hits.
diff -u "$OUT/before.json" "$OUT/after.json" && echo "No differences in resolved targets."

A shell script that saves the resolved targets from nx show project --json before the migration, captures them again after the plugins take over, and diffs the fields that control execution and caching.

How do you migrate a backend service from project.json to inferred targets?

Start on a clean branch with a committed baseline, so every later change shows up as a reviewable diff. Pick one representative service, not your simplest one, and record its current state: run nx show project <name> --json and save the target list, each target's outputs, and the paths where build artifacts actually land on disk. This snapshot is what you compare against later.

Next, register the relevant plugin in the plugins array of nx.json. At this stage the explicit project.json targets still take precedence over the inferred ones, so behavior should not change. Some plugins ship migration generators that move configuration into the tool's own config file and remove redundant targets. Generator names differ between plugins and versions, so run nx list <plugin> against your installed version to see what exists, instead of copying a command name from a blog post. Treat the generated diff like any other pull request and review it line by line.

If you migrate by hand, remove the duplicated targets one at a time. After each removal, run build, test and lint. Then check three things against your snapshot: the output paths match, a second run is a cache hit, and changing a source file causes a cache miss. Removing targets one by one means that when something breaks, you already know which removal caused it.

Target names deserve particular care. Inferred names often differ from what your repo uses, and CI scripts, Dockerfiles, IDE run configurations and dependsOn references all depend on the existing names. Set the plugin's target-name options, such as targetName or buildTargetName depending on the plugin, to your old names. That way the rest of your tooling keeps working without any changes.

#!/usr/bin/env bash
# Incremental migration check for one representative service.
set -euo pipefail
PROJECT=orders-api
DIST=dist/apps/$PROJECT

# 1. Start from a committed, clean baseline so every change is a reviewable diff.
test -z "$(git status --porcelain)" || { echo "Commit or stash first"; exit 1; }
git switch -c chore/orders-api-inferred-targets

# 2. Snapshot target names and their declared outputs.
snapshot() {
  npx nx show project "$PROJECT" --json \
    | jq -S '.targets | with_entries(.value = {outputs: .value.outputs})'
}
snapshot > /tmp/targets-before.json
rm -rf "$DIST" && npx nx build "$PROJECT" --skip-nx-cache
find "$DIST" -type f | sort > /tmp/artifacts-before.txt

# 3. See which generators your installed plugin version provides.
#    Do not assume a generator name. Review any generated diff line by line.
npx nx list @nx/webpack

# 4. Run this after registering the plugin and after EACH removal from project.json.
verify() {
  for t in build test lint; do npx nx run "$PROJECT:$t"; done
  # Target names must still match (set targetName/buildTargetName if not).
  # Outputs may be written differently but must resolve to the same paths.
  snapshot | diff /tmp/targets-before.json - || true
  rm -rf "$DIST" && npx nx build "$PROJECT" --skip-nx-cache
  find "$DIST" -type f | sort | diff /tmp/artifacts-before.txt -
  # The second run should be reported as a cache hit.
  npx nx build "$PROJECT"
  # Touch a source file. The next run should be a cache miss.
  touch "apps/$PROJECT/src/main.ts" && npx nx build "$PROJECT"
}
verify

A shell workflow that records a service's target names, declared outputs and on-disk artifacts, then reruns build, test and lint after each project.json target removal to confirm that targets, output paths and caching behave as before.

How do you keep backend builds and caching correct after migration?

Renaming targets keeps scripts working, but it does not guarantee the cache is correct. Inferred targets typically hash the plugin's config file and source files. A backend artifact often also depends on things the plugin cannot see.

Deploy consumers. If a Dockerfile copies a build output path or a deploy script expects one, declare that path in outputs. Otherwise a cache hit restores nothing to the location the image build reads from.

Serve and watch targets. Long-running dev servers should not be cached. Mark them cache: false so Nx never replays a "completed" watch process.

Environment configurations. If production and staging builds differ through env vars or a runtime config file, add those variables as env inputs and the config file as a file input. A staging cache hit should never satisfy a production build.

Generated code. Protobuf and OpenAPI clients need a generate target. Add a dependsOn from build to generate, and include the schema files in its inputs, so a changed .proto file invalidates everything downstream.

A cache miss costs minutes. A wrong cache hit ships a stale binary. Verify the hash inputs for every target that produces a deployable artifact.

Belongs in project.json Belongs in nx.json targetDefaults
tags and implicitDependencies dependsOn rules shared by all services
Custom targets with no tool config file Shared env and runtime-config inputs
One-off overrides for a single service Shared outputs and caching policy
// nx.json: shared backend caching rules (Nx accepts comments in this file)
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": [],
    "production": ["default", "!{projectRoot}/**/*.spec.ts"]
  },
  "targetDefaults": {
    "build": {
      // Regenerate protobuf/OpenAPI code first, then build upstream libs
      "dependsOn": ["generate", "^build"],
      "inputs": [
        "production",
        "^production",
        // Different env values produce different hashes, so a staging hit cannot satisfy production
        { "env": "APP_ENV" },
        { "env": "API_BASE_URL" },
        // Runtime config files that end up in the artifact
        "{workspaceRoot}/config/runtime.*.json"
      ],
      // The path the Dockerfile COPYs from; restored on a cache hit
      "outputs": ["{projectRoot}/dist"],
      "cache": true
    },
    "generate": {
      // Schema changes invalidate generate, and therefore build
      "inputs": ["{projectRoot}/proto/**/*.proto", "{workspaceRoot}/openapi/*.yaml"],
      "outputs": ["{projectRoot}/src/generated"],
      "cache": true
    },
    // Long-running watch servers must never be replayed from cache
    "serve": { "cache": false }
  }
}

An nx.json targetDefaults block that adds env vars, runtime config files and schema files to build inputs, declares the Docker-consumed output path, chains build to generate, and disables caching for serve.

When should you avoid inferred tasks, and what are the alternatives?

Inference works best when a tool's config file already describes the build. It fits poorly in three cases. The first is a service built by a heavily customized executor whose behavior no config file captures. The second is output written to directories the plugin cannot read from config. The third is a multi-step pipeline, such as codegen, compile, then bundle, that needs so many targetDefaults and per-project overrides that a reader can no longer predict the final target without running nx show project. At that point an explicit target is easier to review.

Scale has costs too. Every plugin scans for its config files when Nx computes the project graph. In a large workspace with many plugins and broad include patterns, that slows graph calculation, especially when the daemon is off, as it often is in CI. Failures also behave differently. A syntax error in one service's tool config breaks graph creation, so every Nx command fails, including commands for unrelated projects. With explicit targets, the same error fails only that one task.

Approach Good fit Trade-off
Explicit executor targets Custom executors, unusual outputs Duplicated config; you maintain inputs/outputs by hand
Hybrid (inferred + explicit) Most services standard, a few special Two places to look when debugging
run-commands-style wrappers Tools with no Nx plugin You must declare caching inputs and outputs yourself

Inferred target names and defaults come from plugin options and plugin versions. Set target names explicitly in plugin options. Re-check nx show project after each upgrade, because CI scripts and dependsOn rules that reference a renamed target will break.

How should you roll out inferred tasks across many services, and what does it cost?

Rather than switching the whole workspace at once, migrate in slices. One option is per plugin: register a single plugin and use its include/exclude glob options in nx.json, where your Nx version supports them, to limit inference to a few service directories. The other is per project: delete one service's redundant project.json targets at a time. Tags are useful for picking which services go first, such as low-traffic internal APIs, but tags do not control inference. The glob patterns do.

For each slice, open one pull request and run nx affected -t build test lint in CI. Run it on the base branch and on the migration branch, then compare which projects are affected, the resulting task graph, and the produced output paths. If the same inputs are now missing the remote cache, your inputs or named inputs have changed, and you should find out why before merging.

The cost comes mostly at the start:

  • Verifying each service.
  • Teaching the team to read nx show project instead of a JSON file.
  • Living with a mixed state where some targets are inferred and others are explicit.

Time-box the mixed state, because every engineer pays for the ambiguity until it ends.

Illustrative estimate (assumed numbers, not a benchmark). Assume 40 services, 1.5 hours to verify each, and 16 hours of team training. Assume config maintenance drops by 0.5 hours per service per quarter, and each new service saves 2 hours of setup, with 6 new services a year.

  • Cost = 40 × 1.5 + 16 = 76 h
  • Yearly savings = 40 × 0.5 × 4 + 6 × 2 = 92 h

Under these assumptions, the migration pays for itself in about ten months. Replace the inputs with your own numbers before deciding.

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 tasks, 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.