What Nx inferred tasks are and how createNodes builds targets

Task inference, often called Nx Crystal, lets Nx derive a project's targets from the tool configuration files already in the repository rather than from hand-written entries in project.json. You register plugins in the plugins array of nx.json, such as the Vite, Jest or ESLint plugins. Each plugin declares a glob pattern for the files it cares about, for example vite.config.ts or jest.config.ts. It also exports a createNodes function.

While Nx builds the project graph, it finds every file that matches a registered plugin's pattern and passes it to that plugin's createNodes. The function reads the file and returns a project fragment rooted at that file's directory. The fragment contains targets, the command each target runs, cache settings, and the inputs and outputs that determine cache hits and which artifacts are restored. A single vite.config.ts can therefore produce build, serve and test targets whose output paths come from the Vite config itself, not from a second copy kept in sync by hand.

That last point is why teams adopt inference. In a project.json-only setup, the build output directory or test command is described twice, once for the tool and once for Nx, and the two drift apart. With inference, the tool config is the single source.

Platform and backend teams that run many similar services or libraries benefit most. One plugin registration replaces dozens of near-identical target blocks.

Treat plugin output as generated configuration that you inspect and verify, the same way you would review a lockfile diff. Do not trust it blindly.

+---------------------------------------------------------------+
| nx.json  plugins: [ vite, jest, eslint, ... ]                 |
+-------------------------------+-------------------------------+
                                | each plugin: glob + createNodes
                                v
+---------------------------------------------------------------+
| Project graph build: Nx scans the repo for matching files     |
|   apps/api/vite.config.ts    libs/auth/jest.config.ts  ...    |
+-------------------------------+-------------------------------+
                                | one call per matched file
                                v
+---------------------------------------------------------------+
| plugin createNodes(matched config file)                       |
|   reads the tool config, e.g. build.outDir in vite.config.ts  |
+-------------------------------+-------------------------------+
                                | returns fragment rooted at
                                v the config file's directory
+---------------------------------------------------------------+
| Project fragment rooted at apps/api                           |
|   targets: build, serve, test                                 |
|   per target: command, cache, inputs, outputs                 |
|   outputs taken from the tool config, not a hand-kept copy    |
+-------------------------------+-------------------------------+
                                | merged into
                                v
+---------------------------------------------------------------+
| Project graph: inferred targets merged with project.json      |
+-------------------------------+-------------------------------+
                                | review like a lockfile diff
                                v
+---------------------------------------------------------------+
| Engineer inspects generated targets before trusting them      |
+---------------------------------------------------------------+

Plugins registered in nx.json match tool config files during project graph construction, and each plugin's createNodes turns a match into a project fragment with targets, cache settings, inputs and outputs that you should review like generated code.

How inferred targets merge with targetDefaults and project.json

Nx assembles a project's final configuration in layers. Plugins listed in the plugins array of nx.json run first, in array order. When two plugins contribute a target with the same name, the later plugin's values merge over the earlier one's, so reordering registrations can change behavior without any edit to the targets themselves. Next, targetDefaults from nx.json is applied. Explicit targets in project.json or package.json are applied last and take precedence.

The merge works per property. It does not replace the whole target. Suppose an inferred build target already has outputs and cache: true, and nx.json sets targetDefaults.build.dependsOn to ["^build"]. The project's project.json then declares only build.options.configFile. The resolved target combines all three sources, yet no single file shows the complete result. Options merge key by key. A field you redefine, such as outputs, replaces the inherited value instead of extending it. If the explicit target names a different executor, treat it as a new target and do not expect any inherited options to carry over.

This is why partial overrides are easy to misread. Reviewers see one line in project.json but not the inferred cache inputs underneath it. Check the resolved target with nx show project <name> before you rely on it.

Source Applied Typical use Risk
Plugin inference First, in plugin order Targets derived from tool config files Plugin order silently decides conflicts
targetDefaults Second Workspace-wide dependsOn, cache, inputs Affects every matching target at once
project.json / package.json Last Per-project exceptions Partial overrides hide inherited values
# nx.json: one inference plugin plus a workspace-wide default for every target named "build".
# If a second plugin also inferred "build", whichever is listed later would merge over this one.
cat > nx.json <<'EOF'
{
  "plugins": [
    { "plugin": "@nx/vite/plugin", "options": { "buildTargetName": "build" } }
  ],
  "targetDefaults": {
    "build": { "dependsOn": ["^build"], "cache": true }
  }
}
EOF

# apps/my-app/project.json: a partial override that sets only "outputs".
# Nx does not append this array to the inferred outputs. It replaces them.
# The command, inputs and dependsOn still come from the plugin and targetDefaults.
cat > apps/my-app/project.json <<'EOF'
{
  "name": "my-app",
  "targets": {
    "build": { "outputs": ["{projectRoot}/dist"] }
  }
}
EOF

# Print the resolved target, which no single file contains in full.
nx show project my-app --json | jq '.targets.build'

A one-line project.json override on an inferred Vite build target. The fully resolved target, assembled from plugin inference, targetDefaults and project.json, can only be seen with nx show project.

Inspecting what Nx inferred before you trust it

Because three layers can contribute to a single target, the target you see in project.json is rarely the one that runs. Before deleting any target, look at the resolved configuration.

nx show project <name> --web opens the project details view. Each target appears as Nx computed it after plugins, targetDefaults and project files were merged. The view also shows which source contributed each part: the plugin and config file that created the target, and any property overridden by nx.json or project.json. If a target you planned to delete is not attributed to a plugin, the plugin is not inferring it. Deleting it would remove the target outright, not hand it to inference.

For each target, check four things:

  • Command and options. Confirm the inferred command matches what your hand-written target ran, including the working directory and config file path.
  • Inputs. These determine the cache key. Missing inputs cause stale cache hits. Overly broad inputs cause needless misses.
  • Outputs. If they don't match where the tool actually writes, a cache hit restores nothing useful.
  • dependsOn. Confirm upstream builds still run first.

nx graph gives the workspace-level view. Use it to check that task dependencies across projects did not change shape.

Inference is code that ships with Nx and its plugins, so an upgrade can change your targets even if your repository does not change. Save the JSON form of nx show project for critical projects before an upgrade, then save it again after the upgrade. Diff the two files and treat any change in inputs, outputs or dependencies as something to review before merging.

#!/usr/bin/env bash
# Snapshot resolved targets before and after an Nx upgrade, then diff them.
set -euo pipefail

PROJECTS=(web-app api shared-ui)   # critical projects to watch
mkdir -p nx-snapshots/before nx-snapshots/after

snapshot() {
  local dir=$1
  for p in "${PROJECTS[@]}"; do
    # --json prints the fully merged project config (plugins + targetDefaults + project.json).
    # jq -S sorts keys so the diff shows real changes, not reordering.
    npx nx show project "$p" --json | jq -S '.targets' > "nx-snapshots/$dir/$p.json"
  done
  # Workspace-level task graph shape across projects.
  npx nx graph --file="nx-snapshots/$dir/graph.json"
}

snapshot before

# Upgrade Nx and its plugins, then apply generated migrations.
npx nx migrate latest
npm install
npx nx migrate --run-migrations --if-exists

# Drop cached project graph so inference reruns with the new plugin code.
npx nx reset

snapshot after

# Any change in command, inputs, outputs or dependsOn needs review before merging.
diff -ru nx-snapshots/before nx-snapshots/after || echo "Resolved targets changed: review the diff above."

A script that saves the resolved targets from nx show project --json and the nx graph output before and after an Nx upgrade. It then diffs the snapshots, so inference changes to inputs, outputs or dependsOn show up even when no file in the repo was edited.

A step-by-step project.json migration, one tool at a time

Migrate one tool at a time so that any regression points to a single plugin.

1. Register one plugin, narrowly scoped

Add a single entry to the plugins array in nx.json. For example, { "plugin": "@nx/jest/plugin", "include": ["libs/payments/**"], "options": { "targetName": "test" } }. The include and exclude globs keep the plugin away from projects you have not reviewed yet. The targetName option keeps the inferred target named test, so CI scripts that call nx affected -t test still work. Option names differ between plugins, so check the plugin's documentation.

2. Compare field by field

Run nx show project <name> --json and compare the inferred target with the explicit one in project.json. While both exist, project.json wins on merge, so the output shows the merged result. To see the inferred target alone, temporarily rename the explicit target, or inspect a scratch copy of the project.

Field What to check
command / executor Same binary, same config file
options Flags you passed explicitly are still present
inputs No missing files or env vars, otherwise you get false cache hits
outputs Same paths, otherwise restored artifacts go missing
dependsOn Upstream builds still run first

3. Delete only on a full match

When every row matches, remove the explicit target. If one field differs, move that field into targetDefaults or leave it in project.json. Do not accept the difference.

4. Verify, then widen

Run nx affected -t build test and confirm the cache hits look reasonable. Then widen include to more projects.

Some official plugins ship a generator that converts explicit targets to inferred ones. Check the documentation for your installed version before you assume one exists.

{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "plugins": [
    {
      "plugin": "@nx/jest/plugin",
      "include": ["libs/payments/**"],
      "exclude": ["libs/payments/legacy-*/**"],
      "options": {
        "targetName": "test"
      }
    }
  ],
  "targetDefaults": {
    "test": {
      "inputs": ["default", "^production", { "env": "PAYMENTS_API_MODE" }]
    }
  }
}

An nx.json that registers only the Jest plugin, scoped to libs/payments with include/exclude globs, keeps the inferred target named "test" for CI, and adds a missing env input through targetDefaults.

Writing a minimal custom plugin with createNodes

When no official plugin covers a tool, such as a Go service or an in-house code generator, a small local plugin can give it the same inferred-target behavior. The plugin exports createNodes as a pair: a file glob such as **/go.mod, and a function that Nx calls with the matched file paths. The function returns project definitions keyed by project root, each with its targets. The export name and argument shape have changed between Nx releases, so use the list below as a guide to the fields you need to fill in, and check the types shipped with your installed version for the exact signature.

Each target needs these fields:

  • command: the shell command to run, for example go build ./....
  • cwd: usually the project root, so relative paths resolve the same way locally and in CI.
  • inputs: everything that should invalidate the cache. Include source file globs. Also add environment variables such as GOOS and GOARCH as env inputs, because they change the output without changing any file.
  • outputs: the directories Nx restores on a cache hit, such as {projectRoot}/bin. If you omit them, a cache hit replays the logs but produces no binary.
  • cache: true: set this only after the inputs are complete.

Use named inputs to keep test edits out of the build cache. Have build depend on production, which excludes *_test.go, and have test depend on default. Editing a test then reruns tests but does not trigger a rebuild.

Nx runs this function on every project graph computation, so keep it cheap:

  • Read only the matched files.
  • Never spawn go list or make network calls.
  • Return targets in a stable order.

Nx caches plugin results based on the contents of the matched files. That cache only works if your output is deterministic. Unit-test the function against fixture files so you catch regressions before they break the project graph for everyone.

When to avoid inference and what the alternatives are

Inference pays off when many projects use the same tool in the same way. It hurts when a target is unique, sensitive, or rarely touched.

Three kinds of targets usually stay clearer as explicit configuration. One-off scripts, such as a data backfill or a schema dump, have no matching tool config file for a plugin to detect, and writing a plugin for a single project costs more than the target saves. Deploy steps that depend on environment-specific secrets should be visible in the diff that changes them. A reviewer approving a production deploy should not have to reconstruct which plugin options produced it. Bespoke nx:run-commands targets that chain shell commands with project-specific arguments express intent better inline than through a generalized plugin.

Inference also makes the workspace less explicit for people. A new team member who opens project.json sees fewer targets than actually exist, and a reviewer can't see a target's change in the PR that caused it. A workspace in the middle of a migration adds a further problem: some projects define build explicitly and others infer it, so the same target name follows different rules depending on where you look.

Approach Explicitness Auditability Upgrade risk Maintenance
Fully explicit project.json High High, every change is in the diff Low High duplication
package.json scripts High High Low Moderate, limited caching metadata
targetDefaults-heavy Medium Medium Low Low
Full inference Low Low without inspection tooling Higher, plugin updates change targets Lowest
Partial migration as end state Mixed Per target Contained Moderate

Partial migration is a legitimate place to stop. Infer the repetitive build, test and lint targets, and keep deploys and scripts explicit. Write down that split so contributors know which rule applies to which kind of target.

Impact on development cost and engineering time, plus debugging

The savings come from deleting config, not from faster builds. Every target you stop declaring is one less block to keep in sync with the tool's own config file. A new project gets build, test and lint targets as soon as its tool config file lands, with no generator run and no copy-paste.

Illustrative estimate (assumed numbers, not a benchmark): Yearly hours saved = (new projects × minutes to wire targets + repo-wide target changes × projects affected × minutes per edit) ÷ 60 − plugin upkeep hours − upgrade-review hours.

Assume 20 new projects × 15 min, 4 changes × 60 projects × 3 min, 8 h of plugin upkeep and 6 h of reviewing inferred-target diffs after upgrades: (300 + 720) ÷ 60 − 8 − 6 = 3 hours.

That margin is thin. Run the formula with your own numbers before committing to custom plugins.

The cost the formula misses is cache correctness. If a plugin's inputs omit a config file the tool reads, Nx can replay stale outputs. If the inputs are too broad, the target rarely hits the cache. When you review an inferred target, check its inputs and outputs, not just its name.

Debugging checklist

  • Not registered: confirm the plugin appears in the plugins array in nx.json.
  • Globs: check that include/exclude patterns actually match the project's config file path.
  • Plugin order: when two plugins infer the same target name, the later entry wins. Reorder the entries or rename one target.
  • Explicit overrides: a project.json target or a targetDefaults entry may be partially overriding inferred options.
  • Stale state: if the graph doesn't reflect your changes, run nx reset to clear the daemon and cached graph, then inspect the project again.

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.
  • 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.