What Project Crystal and Nx inferred tasks actually are

Project Crystal is the name for a change in where Nx gets its project graph. Earlier, you declared each target by hand in project.json: build, test, lint, plus their executors, options, inputs and outputs. With Crystal, you register nx plugins in nx.json. Each plugin scans the workspace for the config files of a tool it understands, such as vite.config.ts, jest.config.ts, an ESLint config or tsconfig.json. It then creates the matching targets for you. These are nx inferred tasks.

Why does this help? The tool's own config already defines what a build or test run reads and writes. A hand-written target repeats that information, and the copy goes stale when someone edits the Vite config but not project.json. Inference reads the tool config directly, so the tool config becomes the source of truth. The plugin derives the command, the cache inputs and the output paths from that file.

Platform and backend engineers who maintain dozens of near-identical services or libraries benefit most. With hand-written targets, each new project needs the same boilerplate, and those copies drift apart over time. With inference, adding a jest.config.ts is enough for the project to get a cacheable test target.

Approach Where targets live Cache inputs/outputs Drift risk
Inferred tasks (Crystal) Plugin reads tool config Derived by plugin Low
Explicit project.json Per-project JSON Hand-declared High across many projects
nx section of package.json Per-package JSON Hand-declared High
Plain package.json scripts Scripts only None unless set in targetDefaults Caching often missing

Who should use inferred tasks

Inferred tasks fit best when a workspace has many projects built with the same small set of tools, and those tools already have config files that describe their inputs and outputs.

Good candidates:

  • Platform teams running a shared monorepo. You own the conventions for how every library builds and tests. Inference lets you express those conventions once, in plugin registration and targetDefaults, instead of reviewing the same project.json boilerplate in every new package.
  • Teams on mainstream JavaScript and TypeScript tooling. If your projects use tools that have official Nx plugins, such as Vite, Jest or ESLint, most of your targets can come from inference with little or no extra configuration.
  • Teams with in-house tools that follow a consistent layout. A codegen step or a Go service with a predictable marker file can be covered by a small local plugin, described later in this article.
  • Workspaces that are growing. The benefit scales with project count. Adding a project should not mean copying and editing a target block.

Weaker candidates:

  • Small workspaces with a handful of projects. The boilerplate inference removes is small, and engineers still have to learn how plugins and precedence work.
  • Workspaces dominated by bespoke build scripts. If most targets wrap custom shell commands with no config file a plugin can read, there is little to infer.
  • Teams where every build command must be visible in review. Inference moves the command out of the diff. The "When explicit targets are the better choice" section covers this trade-off.

How it works: the lifecycle of an inferred target

At a high level, an inferred target goes through two phases: graph construction and task execution.

Graph construction. When you run any Nx command, Nx needs the project graph. For each plugin registered in nx.json, it matches the plugin's glob against the workspace files, filtered by any include and exclude globs. For each matched file, the plugin returns targets for the project that contains it. Nx then layers targetDefaults and per-project configuration on top. It caches the result, and the Nx daemon keeps it in memory, so it only recomputes when a relevant file changes.

Task execution. When you run a target, Nx reads its resolved definition from the graph. It runs dependsOn tasks first, computes a hash from the target's inputs (files, named inputs, environment variables and runtime values), and checks the cache for that hash. On a hit, it restores the files listed in outputs and replays the terminal output. On a miss, it runs the command and stores the outputs under the new hash.

From the task runner's point of view, an inferred target and a hand-written target are the same kind of object. The difference is only where the definition came from. That is why the inputs a plugin infers matter so much: they decide what goes into the hash. The next sections cover each phase in detail: how plugins produce targets, how precedence resolves the final definition, how to inspect it, and how to keep the cache correct.

What are the alternatives to inferred tasks?

The alternatives are all ways of declaring targets explicitly, plus not using Nx targets at all. They can be combined with inference in the same workspace, since inference is only the lowest layer of the precedence chain.

  • Explicit targets in project.json. Each project lists its targets, executors or commands, options, inputs and outputs. This was the standard approach before Crystal. Everything is visible in one file per project, which helps code review and onboarding. The cost is duplication: the same target block repeats across projects and has to be kept in sync with each tool's config by hand.
  • The nx field in package.json. This is the same model as project.json, stored inside the package manifest. It suits workspaces that prefer one file per package. It has the same duplication and drift trade-offs.
  • Plain package.json scripts. Nx can run scripts as targets. Without further configuration it does not know their inputs or outputs, so caching is either off or relies on broad defaults. Adding cache, inputs and outputs for those target names in targetDefaults closes much of the gap, but only when the scripts behave the same way across projects.
  • Explicit targets with heavy use of targetDefaults. Keep targets declared per project, but move shared dependsOn, cache and inputs settings into nx.json. This removes much of the repeated policy without handing command derivation to a plugin. It is a reasonable middle ground for teams that want commands visible in each project but not the full boilerplate.
  • No task orchestration layer. Running each tool directly through the package manager's workspace commands avoids learning Nx's model. You give up the project graph, affected-project detection, task ordering and caching, which are usually the reasons for adopting Nx in the first place.

In practice, most workspaces that adopt inference keep some explicit targets for the cases inference handles poorly. The migration section below covers how to choose.

How an inference plugin turns config files into targets

Inference starts in the plugins array of nx.json. Each entry is either a bare plugin name or an object with a plugin field plus optional options, include, and exclude. The plugin exports a glob, for example one matching vite.config.* or jest.config.*. Nx runs that glob across the workspace and, for every match, asks the plugin what targets the containing project should get. The plugin returns full target definitions: the command, inputs, outputs, cache, and dependsOn. These definitions are derived from the file's location and, for some plugins, from its contents.

Options usually control target naming, such as buildTargetName or testTargetName. Naming matters because downstream configuration refers to targets by name, including dependsOn chains, CI scripts, and targetDefaults. include and exclude take globs that narrow which matched files the plugin considers. You need them when one tool's config files exist for two different purposes, such as unit and end-to-end Jest suites living in separate directories.

Overlap is the main hazard. If two plugins match the same project and emit the same target name, the results merge, and the plugin listed later in the array takes precedence on conflicting fields. If they emit different names for the same tool, you get duplicate targets that run the same work twice. Use include/exclude scoping or distinct target names to keep each plugin's responsibilities separate.

Inference also has a cost. Building the project graph requires globbing the workspace, and some plugins load and evaluate config files to read output paths. Nx caches these results based on file hashes, and the daemon keeps them warm, so it only recomputes them when a matched file changes.

{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "plugins": [
    {
      "plugin": "@nx/vite/plugin",
      "options": {
        "buildTargetName": "build",
        "testTargetName": "vite-test",
        "serveTargetName": "serve"
      }
    },
    {
      "plugin": "@nx/jest/plugin",
      "exclude": ["apps/*-e2e/**/*"],
      "options": {
        "targetName": "test"
      }
    },
    {
      "plugin": "@nx/jest/plugin",
      "include": ["apps/*-e2e/**/*"],
      "options": {
        "targetName": "e2e"
      }
    }
  ],
  "targetDefaults": {
    "e2e": {
      "dependsOn": ["^build"]
    }
  }
}

An nx.json that registers the Vite plugin with explicit target names and the Jest plugin twice, using include/exclude to split unit suites (test) from end-to-end suites under apps/-e2e (e2e), so no project gets duplicate Jest targets and targetDefaults can refer to the e2e target by name.*

Precedence: plugin inference, nx.json targetDefaults, then project.json

Those cached inference results are only the first layer of what a project ends up running. Nx builds each target in three passes. Inference plugins produce the base definition. Then targetDefaults in nx.json applies workspace-wide adjustments by target name or executor. Finally, project.json or the nx field in package.json overrides values for a single project. Each later layer wins, but it does not win the same way for every field.

Object fields such as options merge key by key, so a project can change one option and keep the rest. Array fields such as inputs, outputs and dependsOn are replaced as a whole. If a project sets dependsOn to add one extra task, it also drops the ^build dependency it inherited. That build then runs without its upstream libraries, or it reads stale output from them. Nx reports no error, because the configuration is valid.

Layer Scope Objects (options) Arrays (inputs, dependsOn)
Plugin inference Every project with the matching config file Base values Base values
nx.json targetDefaults Every target with the matching name or executor Merged over the base Replace the base
project.json / package.json One project Merged over the earlier layers Replace the earlier layers

The same reach that makes targetDefaults useful makes it risky. Editing a single entry for test changes every project that has a test target, and the diff touches only one line. Use targetDefaults for policy that should really apply everywhere: dependsOn chains, cache: true, and references to named inputs such as production. Keep project.json for genuine exceptions. When a project overrides an array, write out the complete list of values it needs, not just the addition.

// nx.json: workspace-wide policy, set once
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*"],
    "production": ["default", "!{projectRoot}/**/*.spec.ts"]
  },
  "targetDefaults": {
    // Applies to EVERY project with a "build" target
    "build": {
      "dependsOn": ["^build"],
      "cache": true,
      "inputs": ["production", "^production"]
    },
    "test": {
      "cache": true,
      "inputs": ["default", "^production"]
    }
  }
}

// apps/api/project.json: one real exception
{
  "name": "api",
  "targets": {
    "build": {
      // Arrays REPLACE the inherited value, so restate "^build".
      // Writing only ["generate-client"] would silently drop upstream builds.
      "dependsOn": ["^build", "generate-client"],
      // Objects MERGE: only this key changes; other options are kept.
      "options": {
        "outputPath": "dist/apps/api-server"
      }
    }
  }
}

Shared dependsOn, cache and named inputs live once in nx.json targetDefaults, while one project's override restates the full dependsOn array because arrays replace inherited values and options merge key by key.

Inspecting the resolved result with nx show project

Before opening project.json, nx.json, or a tool's config file to work out why a target behaves the way it does, run nx show project <name>. Reading the files means doing the merge in your head: plugin inference, then targetDefaults, then project overrides, with array replacement rules applied at each step. Nx has already done that merge, and this command prints its result. That result is what the task runner and the cache use.

Read three things for each target:

  • Command and options. These show what will actually execute.
  • Inputs. Named inputs expanded into concrete file sets, plus any environment or runtime inputs. Missing entries here explain stale cache hits.
  • Outputs. The paths Nx will restore on a hit. Missing entries here explain why a cached build leaves dist empty.

Add --web to open the project details view. It attributes each value to its origin: the plugin that inferred it, a targetDefaults entry, or the project's own configuration. That attribution answers "where did this come from?" directly, which is usually what you need when a value looks wrong.

The terminal output is also useful during upgrades. A plugin release can rename inferred targets, change default inputs, or adjust outputs without any change appearing in your repository's diff. Before bumping an Nx plugin, save nx show project <name> --json for a few representative projects. Run it again after the upgrade and diff the two files. Any difference is a behavioural change you can review before merging, instead of finding it later through unexpected cache misses or a CI step that calls a target name that no longer exists.

#!/usr/bin/env bash
set -euo pipefail

PROJECTS=(web-app api shared-ui)
mkdir -p .nx-snapshots/before .nx-snapshots/after

snapshot() {
  local dir=$1
  for p in "${PROJECTS[@]}"; do
    # Resolved config after inference, targetDefaults and project overrides.
    # jq -S sorts keys so the diff only shows real changes, not key reordering.
    npx nx show project "$p" --json | jq -S '.targets' > "$dir/$p.json"
  done
}

snapshot .nx-snapshots/before

npx nx migrate latest
npm install
npx nx migrate --run-migrations

npx nx reset

snapshot .nx-snapshots/after
diff -ru .nx-snapshots/before .nx-snapshots/after || echo "Resolved targets changed: review before merging."

Snapshot the resolved targets of a few projects with nx show project --json before and after an Nx plugin upgrade, then diff them so changes in inferred target names, inputs or outputs are reviewed before merging.

Keeping the cache correct when inputs are inferred

An inference plugin derives inputs from what it can read statically: the tool's config file, the source globs that config declares, and the lockfile entries for the tool itself. It cannot see anything resolved at runtime. Common gaps are:

  • Environment variables read by the build.
  • Config assembled dynamically, such as a file that requires another file by computed path.
  • Generated code that lands outside the declared source globs.
  • Files that a package.json script references only by shell argument.

Each gap produces the same failure: Nx computes an identical hash, restores old outputs, and reports success. A stale hit is worse than a miss. A miss costs minutes. A stale hit ships an artifact built against the wrong configuration, and nothing in the logs says so.

To verify, open the resolved target in nx show project and read its inputs list against what the tool actually consumes. Then run the target, change the suspected dependency, and run it again. If the second run is a cache hit, the input is missing.

Here is an illustrative case. A service's build inlines API_BASE_URL at compile time. The inferred build target hashes sources and bundler config but not the variable, so a staging build can be restored into a production pipeline. The fix is to declare the dependency as an env input. Put it in a named input in nx.json and reference that named input from targetDefaults if every build of that type reads the variable. Put it in a project.json override if only this service does. If your plugin infers inputs, the override should add to the inferred list rather than replace it, so the plugin's file-based inputs stay intact.

npx nx show project my-service --json | jq '.targets.build.inputs'

API_BASE_URL= npx nx run my-service:build
API_BASE_URL= npx nx run my-service:build
API_BASE_URL= npx nx run my-service:build
API_BASE_URL= npx nx run my-service:build

This example inspects a build target's inferred inputs, probes whether the cache misses API_BASE_URL, and declares the variable as an env named input.

Writing a small local plugin for in-house tooling

The same mechanism that powers the official plugins is available for your own tools. A local plugin is a module in the workspace, registered in the plugins array of nx.json, that exports a createNodes-style entry. That entry declares a glob for a marker file and a function Nx calls for every match.

Take a platform-owned codegen tool that reads codegen.yaml, or a Go service whose root contains go.mod. The plugin's job has three steps:

  1. Match the marker file. The glob decides which directories become projects, or which existing projects gain targets. Choose a file that only your tool uses so the plugin never claims directories it doesn't understand.
  2. Derive the project root. This is usually the marker file's directory. Every path the plugin returns should be relative to that root, which keeps the logic identical across all matching projects.
  3. Return targets with inputs, outputs and cache settings. For the Go case, inputs would cover **/*.go, go.mod and go.sum, plus the toolchain version as a runtime or environment input. Outputs would be the binary directory, and cache would be true. Getting the inputs right here is the main reason to write a plugin. The cache-correctness rules from the previous section then apply in one place instead of being copied into dozens of project.json files.

The plugin is production code. It runs on every project-graph computation, so a slow file read or an unhandled parse error breaks the graph for the whole workspace. Treat it like any shared library:

  • Write unit tests against fixture directories.
  • Version changes deliberately, because altering inferred inputs invalidates caches everywhere.
  • Name an owner who will update it when the underlying tool changes.

Migration, when to avoid inference, and impact on engineering time

Migrating one plugin at a time

Register a single plugin in nx.json and use its include/exclude globs to limit it to a few low-risk projects. Before enabling it, save the output of nx show project <name> --json for each of those projects. Enable the plugin, capture the output again, and diff the two. Delete a project.json target only when the resolved command, inputs, outputs and dependsOn match. If you delete it earlier, behaviour changes without any error, and the first sign is often a stale cache hit.

Some targets will still differ on purpose. Leave those in project.json and add a short note explaining why. That file is the highest-precedence layer, built for overrides, so removing an intentional override to "finish" the migration is a regression.

When explicit targets are the better choice

Keep a target explicit in these cases:

  • Unusual commands. Wrapper scripts and environment-dependent flags are hard for a plugin to infer correctly.
  • No backing config file. A plugin can only infer from files it can read.
  • Review-sensitive tasks. For release or deploy targets, reviewers should see the exact command in the diff.

Cost on engineering time

Factor Inference Explicit targets
Boilerplate Minimal Repeated per project
Drift between tool config and Nx config Rare Common
Visibility in code review Low High
Onboarding Must learn the plugins Read the file
Coupling Tied to plugin behaviour Self-contained
Graph creation on large repos Slower, since plugins scan files Faster

A mixed-mode workspace has the most lasting cost. If half the projects infer their targets and half declare them, every engineer has to check two places to answer "what does build run here?" Choose an end state and complete the migration rather than leaving the workspace half-converted.

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.