What inferred tasks are and how createNodes works

Inferred tasks are targets that Nx discovers by reading files already on disk rather than from hand-written entries in project.json. If every service in your repository has a service.yaml, a plugin can read it and register build, test, and deploy targets automatically. When the convention changes, you update one plugin instead of fifty project files.

The mechanism is createNodes. Nx runs plugin code while it computes the project graph. It is best treated as a small, pure compiler: config files go in, project graph fragments come out. The export pairs a glob pattern with a function. Nx finds every file matching the glob and passes the matched paths to the function, along with the plugin's options from nx.json and a context object that includes the workspace root. The function returns project and target configuration keyed by project root, which tells Nx that a project lives at a given directory and has certain targets.

Nx merges these fragments with any explicit configuration. Targets defined in project.json override inferred values for the same target, so teams can adjust a single project without forking the plugin. When several plugins infer the same target, their order in the plugins array of nx.json determines precedence.

Nx has offered both a single-file signature and a batched one that receives all matches at once. Type names have also changed over time. Check the Nx docs for your installed version before you copy any signature.

This approach suits backend and platform engineers who already enforce a repeated config-file convention and want Nx to treat that convention as the source of truth.

+------------------------------------------------------------+
|                     Workspace on disk                      |
|  apps/orders/service.yaml   apps/billing/service.yaml  ... |
+------------------------------------------------------------+
                              |
                              | files matching the glob
                              v
+------------------------------------------------------------+
| Plugin createNodes export                                  |
|  +-----------------+    +-------------------------------+  |
|  | glob pattern    |--->| function(matched paths,       |  |
|  | **/service.yaml |    |   plugin options, context)    |  |
|  +-----------------+    +-------------------------------+  |
+------------------------------------------------------------+
        ^                                   |
        | plugin options                    | fragments keyed
        |                                   | by project root
+---------------+                           v
| nx.json       |         +----------------------------------+
| plugins: [...]|-------->| Project graph merge              |
| (order sets   | order   |  inferred targets (plugin order) |
|  precedence)  |         |  project.json overrides inferred |
+---------------+         +----------------------------------+
                                            |
                                            v
                          +----------------------------------+
                          | Project graph: build/test/deploy |
                          +----------------------------------+

createNodes compiles matched config files, plugin options and context into project graph fragments, which Nx merges by plugin order with project.json overrides.

Building a minimal plugin step by step

Start with a convention you already enforce. Suppose every service that owns a database schema has a migrate.config.json next to its package.json. The plugin's glob is **/migrate.config.json, which is narrow enough that Nx only invokes your function for files that matter, rather than for every JSON file in the repo.

For each matched path, read the file and pull out only the fields that affect the task, such as the migrations directory and the target database. Ignore everything else. Fields you never read cannot change the computed result, so keeping the read small keeps the plugin predictable. The project root is the directory containing the matched file. Derive it from the file path rather than hardcoding a layout, so a service moved under a new folder keeps working.

Emit a target that uses the nx:run-commands executor and calls the real migration CLI with cwd set to the project root. The plugin only describes the task. The tool you already trust does the actual work.

During development, register the plugin in the plugins array of nx.json with a relative path to its source file. Use the object form so you can pass options, such as a targetName that defaults to migrate. This lets teams adapt the plugin to their own naming without editing its code.

Pick that name once and treat it as a public interface. CI pipelines, scripts and developer habits call nx run-many -t migrate. If you rename the target, those invocations break without warning.

Once the plugin behaves well locally, you can publish it as a package and reuse it across repositories.

// tools/nx-migrate-plugin/index.ts
// Registered in nx.json during development:
// "plugins": [{ "plugin": "./tools/nx-migrate-plugin/index.ts", "options": { "targetName": "migrate" } }]
import { dirname, join } from 'node:path';
import { CreateNodesV2, createNodesFromFiles, readJsonFile } from '@nx/devkit';

export interface MigratePluginOptions {
  targetName?: string; // stable public name; CI calls `nx run-many -t migrate`
}

// Only the fields that affect the task. Everything else in the file is ignored.
interface MigrateConfig {
  migrationsDir: string;
  database: string;
}

export const createNodesV2: CreateNodesV2<MigratePluginOptions> = [
  // Narrow glob: Nx calls us only for files that follow the convention.
  '**/migrate.config.json',
  (configFiles, options, context) =>
    createNodesFromFiles(
      (configFile, opts, ctx) => {
        const projectRoot = dirname(configFile); // derived, not hardcoded
        const { migrationsDir, database } = readJsonFile<MigrateConfig>(
          join(ctx.workspaceRoot, configFile)
        );
        const targetName = opts?.targetName ?? 'migrate';
        return {
          projects: {
            [projectRoot]: {
              targets: {
                [targetName]: {
                  // Describe the task; the real migration CLI does the work.
                  executor: 'nx:run-commands',
                  options: {
                    command: `npx migrate-tool up --dir ${migrationsDir} --db ${database}`,
                    cwd: projectRoot,
                  },
                  cache: false, // migrations have side effects; never replay from cache
                },
              },
            },
          },
        };
      },
      configFiles,
      options,
      context
    ),
];

A minimal local Nx plugin that matches migrate.config.json files, reads only the migrations directory and database fields, derives the project root from the file's location, and emits a configurable but stable nx:run-commands target that invokes the migration CLI (migrate-tool is a placeholder).

Declaring cache-correct targets and dependencies

Every target your createNodes function returns carries its own caching contract. Set cache: true, then list inputs and outputs precisely. Nx hashes exactly what you declare and nothing else. It cannot tell when you got the declaration wrong, so cache correctness is now your responsibility as the plugin author.

Two failure cases

Missing input. Suppose your target references only the production named input, and that input excludes the config file that drives the task. A developer edits that config file and reruns the task. The hash does not change, so Nx replays the old result and the build is stale. To prevent this, add the file explicitly with a {projectRoot}-relative path. Do this for any workspace-root files the tool reads as well.

Missing output. If the target does not declare outputs, a cache hit still prints the cached terminal log and reports success. But no files are restored. Any downstream task that reads the generated directory then fails, or uses whatever leftovers are on disk.

Mistake Symptom Nx warns?
Under-declared inputs Stale artifacts ship No
Over-declared inputs (e.g. all of {workspaceRoot}) Hit rate collapses No
Missing outputs Hit restores nothing No

Ordering versus graph edges

dependsOn: ["^build"] makes each dependency's build run first. It only follows edges that already exist in the project graph, though. Suppose your config file is what declares a relationship, such as one service consuming another's schema. In that case dependsOn alone has nothing to traverse. Emit those edges from a createDependencies hook instead. That way the edges also show up in nx affected and the graph view, not just in task ordering.

import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { CreateDependencies, CreateNodesV2, DependencyType, createNodesFromFiles, readJsonFile } from '@nx/devkit';

export const createNodesV2: CreateNodesV2 = ['**/codegen.json', (configFiles, options, context) =>
  createNodesFromFiles((configFile) => {
    const projectRoot = dirname(configFile);
    return { projects: { [projectRoot]: { targets: { codegen: {
      command: 'codegen --config codegen.json',
      options: { cwd: projectRoot },
      cache: true,
      // Ordering only: follows graph edges that already exist.
      dependsOn: ['^codegen'],
      inputs: [
        'production', '^production',
        // 'production' may exclude this file, so list it explicitly or edits replay stale results.
        '{projectRoot}/codegen.json',
        // A workspace-root file the tool reads. Narrow it to that file, not all of {workspaceRoot}.
        '{workspaceRoot}/tsconfig.base.json',
      ],
      // Without this, a cache hit prints the log but restores no files.
      outputs: ['{projectRoot}/generated'],
    } } } } };
  }, configFiles, options, context),
];

// The edge lives in config, so emit it here. dependsOn alone has nothing to traverse.
export const createDependencies: CreateDependencies = (_options, context) => {
  const deps = [];
  for (const [name, project] of Object.entries(context.projects)) {
    const sourceFile = join(project.root, 'codegen.json');
    const fullPath = join(context.workspaceRoot, sourceFile);
    if (!existsSync(fullPath)) continue;
    const { schemaFrom } = readJsonFile(fullPath); // e.g. "orders-api"
    if (schemaFrom) deps.push({ source: name, target: schemaFrom, type: DependencyType.static, sourceFile });
  }
  return deps;
};

A plugin that infers a cached codegen target with explicit config-file inputs and declared outputs, and emits config-defined project edges from createDependencies so that ^codegen has something to follow.

Keeping createNodes fast and deterministic

Nx calls your createNodes function every time it recomputes the project graph. That happens on a cold start in CI, when the daemon notices file changes, and when editor integrations refresh their view of the workspace. Any cost you add lands on every nx run, nx affected and editor refresh, and it scales with the number of files your glob matches. A pattern like **/*.json will pull in thousands of irrelevant files. Match the exact filename your tool owns instead.

A few rules keep the function cheap:

  • No network calls. Graph construction must work offline and must not depend on a remote service being up.
  • No heavy process per matched file. Spawning a compiler or CLI for each config file multiplies startup cost by the number of projects.
  • Parse minimally. Read only the fields that determine targets, inputs and outputs. Ignore the rest.
  • Derive results only from the matched file contents and the plugin options. Treat these as the function's sole inputs.

Determinism matters as much as speed. If you read environment variables, timestamps, the current user or files outside the declared pattern, two machines can build different graphs from the same commit. Nx does not see those extra inputs, so it cannot tell when they change. The result is stale or mismatched graphs that are hard to debug. If something legitimately varies, expose it as a plugin option so it lives in versioned configuration.

When parsing is unavoidably expensive, cache the computed nodes. Key each entry on a hash of the matched file contents, the plugin options and any other files you deliberately read, and persist the cache between runs. On a hit, skip parsing entirely. Because the key covers every input, a changed file produces a new hash and forces recomputation, so the cache never serves a stale graph.

import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { CreateNodesV2, createNodesFromFiles, readJsonFile, writeJsonFile, workspaceRoot } from '@nx/devkit';

type Opts = { buildTargetName?: string };
const cachePath = join(workspaceRoot, '.nx', 'cache', 'mytool-plugin-nodes.json');

export const createNodesV2: CreateNodesV2<Opts> = [
  // Match the one filename this tool owns, not **/*.json.
  '**/mytool.config.json',
  async (configFiles, options, context) => {
    const cache: Record<string, any> = existsSync(cachePath) ? readJsonFile(cachePath) : {};
    try {
      return await createNodesFromFiles((file, opts, ctx) => {
        const contents = readFileSync(join(ctx.workspaceRoot, file), 'utf8');
        // The key covers every input: path, file contents and plugin options.
        // No env vars, timestamps or files outside the pattern feed into it.
        const key = createHash('sha256')
          .update(file).update(contents).update(JSON.stringify(opts ?? {}))
          .digest('hex');
        cache[key] ??= inferTargets(contents, opts ?? {}); // skip parsing on a hit
        return { projects: { [dirname(file)]: { targets: cache[key] } } };
      }, configFiles, options, context);
    } finally {
      writeJsonFile(cachePath, cache); // persist between runs
    }
  },
];

// Parse minimally: read only the field that affects outputs. No network, no child processes.
function inferTargets(contents: string, opts: Opts) {
  const { outDir = 'dist' } = JSON.parse(contents);
  return {
    [opts.buildTargetName ?? 'build']: {
      command: 'mytool build',
      options: { cwd: '{projectRoot}' },
      inputs: ['default'],
      outputs: [`{projectRoot}/${outDir}`],
      cache: true,
    },
  };
}

A createNodesV2 plugin uses a narrow glob and parses minimally, then caches inferred targets on disk under a hash of the file path, file contents and plugin options, so it recomputes only when one of those inputs changes.

Testing and debugging the plugin

Because createNodes is a plain function, you can test it without booting Nx. Import the plugin's createNodes export, take the function from it, and call it with paths to fixture config files in a temporary directory. Pass a hand-built context object with a workspaceRoot pointing at that directory and a minimal nxJsonConfiguration. Then assert on the returned object: the project root, the target names, the inputs and outputs arrays, and dependsOn. These tests run in milliseconds. They also catch the bugs that matter most, such as an output path computed relative to the wrong directory, which would silently break cache restoration. Add one test that calls the function twice with identical inputs and asserts deep equality. That is your determinism check.

Verifying in a real workspace

Once unit tests pass, register the plugin in nx.json and run nx show project <name>. This prints the fully merged configuration, so you see what your plugin contributed after Nx combined it with project.json, package.json and other plugins. Run nx graph to confirm that inferred targets appear and that dependency edges point where you expect.

When changes don't show up

The most common confusion is editing the plugin and seeing no difference. The Nx daemon keeps the project graph in memory and caches plugin results, so it may still be running your old code. Run nx reset to stop the daemon and clear cached graph data. While iterating, set NX_DAEMON=false so each command loads the plugin fresh.

When to use a custom plugin and when to avoid it

A plugin earns its keep when the same config-file convention repeats across many projects. If forty services each have a migrations.yaml and each needs the same db:migrate target, one plugin keeps their inputs, outputs, and caching rules identical. Fixing a cache bug once fixes it everywhere, and affected detection stays consistent because no project carries a hand-edited copy of the rules.

When to skip it

  • Only a few projects need the target. Writing explicit targets in three project.json files is cheaper than writing a plugin, testing it, and keeping it working.
  • No one will maintain it. The plugin API has changed across Nx major versions. A plugin with no owner can block your upgrades, or it can quietly stop producing targets.
  • Newcomers can't find where targets come from. An engineer reads project.json, doesn't see the target they just ran, and loses an afternoon searching for it.

You can reduce the last problem. Document the plugin, including which file patterns it matches, in the repo's contributing guide. Also teach everyone to run nx show project <name>, which prints the fully resolved configuration and shows which targets were inferred.

Merge and precedence surprises

Two plugins can match the same file, or an explicit target can share a name with an inferred one. Nx merges these sources rather than reporting a conflict. Plugins are applied in the order they appear in nx.json, and targets defined in project.json are merged on top of anything inferred. As a result, a partial override such as changing only options can silently keep inferred inputs you assumed were gone. Before relying on an override, check the merged result with nx show project.

Alternatives and impact on cost and engineering time

That merging behaviour is one of the costs to weigh. The four ways to define targets differ mainly in who owns the configuration:

Approach Setup effort Maintenance burden Visibility Cache correctness risk Scaling across projects
Hand-written project.json targets Low per project High, because edits repeat per project High: config sits in the file Medium, since inputs drift between copies Poor
package.json scripts Very low Low High High unless inputs and outputs are added Fair
Existing official or community plugin Low Low, though you depend on upstream releases Medium: inferred Low for its supported tools Good
Custom createNodes plugin Medium to high Medium: you own Nx upgrades Medium: inferred Concentrated in one place you own Good

A custom plugin saves time in a few places. Teams stop writing per-project target boilerplate. Every project gets the same inputs and outputs, so caching and affected runs behave the same way everywhere. CI pipelines can call uniform target names. The costs move to a smaller group of people. They adapt the plugin when Nx APIs change, they debug project graph errors that appear far from the config file that caused them, and a wrong input glob in the plugin breaks cache correctness for every project at once.

Illustrative estimate (assumed numbers, not a benchmark)

Net hours per year = (P × C × t_edit) − (U × t_upgrade + D × t_debug)

Assumed inputs:

  • P = 40 projects
  • C = 6 target changes per project per year
  • t_edit = 0.5 h per change
  • U = 3 Nx upgrades per year
  • t_upgrade = 4 h per upgrade
  • D = 5 graph issues per year
  • t_debug = 3 h per issue

Calculation: (40 × 6 × 0.5) − (3 × 4 + 5 × 3) = 120 − 27 = 93 hours saved per year.

With 5 projects and the same costs, the result is 15 − 27 = −12 hours. At that scale a custom plugin costs more time than it saves.

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 custom nx plugin createNodes, 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.