What an Nx custom executor is and when it beats run-commands or scripts
An Nx custom executor is a typed, reusable task implementation that ships inside an Nx plugin. It has three parts: a JSON schema that validates its options, a function that receives those options with an ExecutorContext describing the workspace and the target project, and a registration that lets any project reference it as my-plugin:build. Nx resolves the executor, merges defaults from the schema, and runs it like any other target, so caching and dependency ordering apply to it the same way.
The case for one is repetition. Suppose forty backend services each generate OpenAPI clients, bundle migrations, or build container contexts in almost the same way. Copied shell snippets drift apart over time. An executor keeps the logic in one place, rejects bad options before the task runs, and reads the project root and configuration through the context instead of hardcoding paths. This work suits a platform team that supports many backend projects, because that team will maintain the code over the long term.
Skip the executor in these cases:
- The task is a one-off.
- A
run-commandstarget or apackage.jsonscript already handles it. - An existing Nx plugin already infers the target.
- The task has external side effects, such as deploys or database writes, which should not be cached.
| Approach | Typing | Reuse | Caching control | Debuggability | Maintenance cost |
|---|---|---|---|---|---|
| package.json script | None | Copy per project | Inputs/outputs in config | Shell-level | Low |
| run-commands target | Loose options | Copy per project | Inputs/outputs in config | Shell-level | Low |
| Inferred plugin target | Plugin-defined | Automatic | Plugin defaults, overridable | Depends on plugin | Low (owned upstream) |
| Custom executor | Schema-validated | Shared across workspace | Defaults set once, centrally | Node debugger, unit tests | Higher (you own it) |
Anatomy of an Nx plugin executor
An Nx plugin executor has three parts, and each has a separate job.
The implementation function is the default export of a module. Nx calls it with two arguments: the validated options and an ExecutorContext. It returns a promise of { success: boolean }, or it can be an async generator that yields such objects for long-running tasks like watchers. For a backend codegen executor, the context supplies everything you need without hardcoding paths. context.root is the workspace root, and the project's root comes from the project configuration keyed by context.projectName. Resolve every option path against the workspace root. Relative paths then behave the same whether Nx runs the task from a terminal, CI, or a daemon process.
schema.json is a JSON Schema for the options. Nx validates against it and applies defaults before your function runs, so the implementation can assume well-formed input.
executors.json is the manifest. The plugin's package.json points to it, and it maps each executor name to an implementation path and a schema path.
project.json
targets.codegen.executor: "@acme/codegen:generate"
│
▼
@acme/codegen/package.json ──"executors"──► executors.json
│ generate:
│ implementation ─► ./src/generate/impl
│ schema ─────────► ./src/generate/schema.json
▼
Nx validates target options against schema.json, fills defaults
│
▼
impl(options, context) ──► { success: true | false }
Log the resolved command and the effective options through the devkit logger before you execute anything. When a generated client is wrong, an application engineer can then see exactly what ran without opening the plugin source.
Plugin APIs, including the shape of ExecutorContext, have changed across Nx releases. Check the documentation for your installed version.
// src/generate/impl.ts: minimal codegen executor (check the API for your installed Nx version)
import { ExecutorContext, logger } from '@nx/devkit';
import { spawnSync } from 'node:child_process';
import * as path from 'node:path';
export interface GenerateOptions {
specPath: string; // relative to the workspace root, validated by schema.json
outputPath: string; // relative to the workspace root
dryRun?: boolean; // default applied from schema.json
}
export default async function generateExecutor(
options: GenerateOptions,
context: ExecutorContext
): Promise<{ success: boolean }> {
// Recent Nx exposes projectsConfigurations. Older releases used context.workspace.
const name = context.projectName;
const project = name ? context.projectsConfigurations?.projects[name] : undefined;
if (!project) {
logger.error(`[codegen] no project configuration found for "${name}"`);
return { success: false };
}
// Anchor every path to the workspace root so terminal, CI and daemon runs agree.
const projectRoot = path.resolve(context.root, project.root);
const args = ['tools/codegen/run.js',
'--spec', path.resolve(context.root, options.specPath),
'--out', path.resolve(context.root, options.outputPath)];
// Log the effective inputs before running so failures can be reproduced by hand.
logger.info(`[codegen] project=${name} root=${projectRoot}`);
logger.info(`[codegen] options=${JSON.stringify(options)}`);
logger.info(`[codegen] command: node ${args.join(' ')}`);
if (options.dryRun) return { success: true };
const result = spawnSync('node', args, { cwd: context.root, stdio: 'inherit' });
if (result.status !== 0) logger.error(`[codegen] failed with exit code ${result.status}`);
return { success: result.status === 0 };
}
A minimal Nx Devkit executor reads the project root from the ExecutorContext and resolves option paths against the workspace root. Before it runs the generator, it logs the effective options and the exact command.
Designing the executor to be deterministic
Whatever version you target, one rule holds across all of them. An executor is only safe to cache if its result is a pure function of its declared options and the files Nx hashes. Every other read is an input Nx cannot see.
The common leaks are small. One is a process.env.SCHEMA_TARGET lookup buried in a helper. Another is a new Date().toISOString() stamped into a generated file's header. A third is a call to a registry or database to discover the "current" schema version. Writing outside the project's declared output directory is just as bad. Nx will not restore those files on a cache hit, so the next run sees a half-populated tree.
The example below shows a codegen executor before and after the fix. In the "before" version, the executor reads the target dialect from an environment variable and embeds a generation timestamp. In the "after" version, dialect becomes a required option in the executor schema. The timestamp is dropped, and if a version marker is needed, it is replaced by a header option or by a hash of the input files. Both values now appear in project.json, so they are hashed and visible in review.
Under-declared behavior causes more harm than skipping caching. Suppose CI sets SCHEMA_TARGET=postgres. A colleague's earlier run with mysql produced the same hash, so Nx replays their output and reports success. For a migration generator, that means committing or deploying SQL written for the wrong engine. Nothing errors, and the log shows a cache hit, so the cause is hard to trace. If the executor had no cache at all, it would be slower but correct.
import { ExecutorContext } from '@nx/devkit';
import { createHash } from 'crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
// BEFORE (hidden inputs Nx cannot hash):
// const dialect = process.env.SCHEMA_TARGET ?? 'postgres';
// const header = `-- generated ${new Date().toISOString()}`;
// AFTER: behavior derives only from declared options and hashed files.
export interface CodegenOptions {
schemaFile: string; // project-relative; covered by the target's inputs
dialect: 'postgres' | 'mysql'; // required in schema.json, set in project.json
outputPath: string; // must match the target's "outputs" entry
header?: string; // optional static marker instead of a timestamp
}
export default async function runExecutor(
options: CodegenOptions,
context: ExecutorContext
): Promise<{ success: boolean }> {
const schema = readFileSync(join(context.root, options.schemaFile), 'utf8');
// Same inputs produce the same bytes, so a cache replay matches a fresh run.
const inputHash = createHash('sha256').update(options.dialect).update(schema).digest('hex').slice(0, 12);
const header = options.header ?? `-- source sha256:${inputHash}`;
// Write only inside the declared output directory so Nx can restore it.
const outDir = join(context.root, options.outputPath);
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, `migration.${options.dialect}.sql`), `${header}\n${render(schema, options.dialect)}`);
return { success: true };
}
// Placeholder for the real generator; it must stay pure (no env, clock or network).
function render(schema: string, dialect: string): string {
return `-- dialect: ${dialect}\n${schema}`;
}
A codegen executor where the env-var dialect lookup and the timestamp header are replaced by a required dialect option and an input-derived hash, with all output written under the declared outputPath.
How Nx caching works and how to declare inputs and outputs
Nx decides whether to run a task by computing a hash. The hash covers the executor's identity, the resolved options, and every declared input:
- File sets, such as
{projectRoot}/src/**/*.ts. - Named inputs like
production, defined once innamedInputsinnx.json. - Dependency inputs or outputs, written with a
^prefix. - Runtime inputs, such as
{ "runtime": "node --version" }. - Environment inputs, such as
{ "env": "DB_DIALECT" }.
When the hash matches a stored entry, Nx does not run the executor. It prints the stored terminal output and restores the files listed in outputs.
In nx.json, set "cache": true, inputs and outputs under targetDefaults. A project can then narrow those settings in its project.json, for example with "outputs": ["{options.outputPath}"]. The env input fixes the dialect bug described above, because a different DB_DIALECT value now produces a different hash.
| Input scope | Failure mode | Symptom |
|---|---|---|
| Under-declared | Stale replay | Cache hit with wrong output |
Whole workspace or all node_modules |
Near-constant misses | Cache never helps |
The correct input set is rarely right on the first attempt. Expect several rounds of comparing hashes after changes that should or should not invalidate the cache.
Never cache database migrations, image pushes or deploys. On a cache hit, Nx replays the log of the earlier run, but the side effect does not happen again.
Do not cache dev servers or watch mode either. Their executors are async function* generators. They yield { success: true } after each rebuild and keep the process running until it is interrupted. There is no final result to store, so set "cache": false on those targets.
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/jest.config.ts"
]
},
"targetDefaults": {
"build": {
"cache": true,
"dependsOn": ["^build"],
"inputs": [
"production",
"^production",
{ "env": "DB_DIALECT" },
{ "runtime": "node --version" }
],
"outputs": ["{options.outputPath}"]
},
"test": {
"cache": true,
"inputs": ["default", "^production", { "env": "DB_DIALECT" }],
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"]
},
"migrate": { "cache": false },
"push-image": { "cache": false },
"deploy": { "cache": false },
"serve": { "cache": false }
}
}
An nx.json that caches build and test with named, dependency, env and runtime inputs, and disables caching for migrate, push-image, deploy and serve.
Testing the executor: unit tests and a fixture-workspace integration test
Unit tests: call the function directly
The executor is an exported async function, so unit tests can skip Nx entirely. Import the implementation, pass an options object, and build a minimal ExecutorContext with root pointing at a temporary directory, projectName, and a projectsConfigurations entry for that one project. Mock the boundaries you don't want to exercise, such as the child-process call to a compiler or schema generator. Then assert on two things. First, check the returned success flag for both valid and invalid options. Second, check the file-system effects: the expected files exist under the output path, nothing was written outside it, and a second call with identical inputs produces byte-identical output.
These tests are fast and catch logic errors, including bad option defaults, wrong path resolution relative to context.root, and nondeterminism such as timestamps or unordered directory listings leaking into artifacts.
Integration test: a fixture workspace
Unit tests cannot tell you whether inputs, outputs and the target defaults are declared correctly. That only shows up when Nx itself runs the task. The integration test does the following:
- Copies a small fixture workspace into a temp directory.
- Installs the locally built plugin.
- Runs
nx run api:your-targettwice. - Asserts that the first run exits with status zero and produces the declared files.
- Asserts that the second run is a cache hit.
To check the cache hit without depending on Nx's log wording, delete the output directory between runs and confirm Nx restores it. Then edit a declared input and confirm the task reruns.
Run this suite in the plugin's own CI. A caching bug doesn't fail any task. It silently serves stale artifacts to every consuming project, which is exactly the kind of regression nobody notices downstream.
// executor.spec.ts: unit tests that call the executor function directly, no Nx runtime
import { mkdtempSync, readFileSync, existsSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import type { ExecutorContext } from '@nx/devkit';
import runExecutor from './executor';
// Mock the child-process boundary so no real compiler or generator runs
jest.mock('child_process', () => ({ execFileSync: jest.fn(() => Buffer.from('')) }));
let root: string;
const makeContext = (): ExecutorContext =>
({
root,
cwd: root,
isVerbose: false,
projectName: 'api',
projectsConfigurations: { version: 2, projects: { api: { root: 'apps/api' } } },
}) as unknown as ExecutorContext; // only the fields the executor reads
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'exec-')); });
afterEach(() => rmSync(root, { recursive: true, force: true }));
it('succeeds and writes only under outputPath, resolved from context.root', async () => {
const res = await runExecutor({ outputPath: 'dist/apps/api' }, makeContext());
expect(res.success).toBe(true);
expect(existsSync(join(root, 'dist/apps/api/manifest.json'))).toBe(true);
});
it('fails on invalid options', async () => {
const res = await runExecutor({ outputPath: '' }, makeContext());
expect(res.success).toBe(false);
});
it('is deterministic: identical inputs give byte-identical output', async () => {
const file = join(root, 'dist/apps/api/manifest.json');
await runExecutor({ outputPath: 'dist/apps/api' }, makeContext());
const first = readFileSync(file);
await runExecutor({ outputPath: 'dist/apps/api' }, makeContext());
expect(readFileSync(file).equals(first)).toBe(true); // catches timestamps or unordered listings
});
Jest unit tests that call the executor directly with a minimal cast ExecutorContext rooted in a temp directory and a mocked child_process. They assert on the success flag, on where output files land, and on byte-identical output across repeated runs.
Wiring the executor into backend projects and verifying the cache
In each backend project's project.json, point the target at the executor with the package:executor form, for example "executor": "@acme/nx-backend:codegen". Nx resolves the package from the workspace's dependencies and finds the executor name in the plugin's executors.json. A typo here fails at run time, not at install time, so run the target once right after adding it.
Settings that every service shares belong in nx.json under targetDefaults: cache: true, the inputs and outputs patterns, and default options. If you repeat them per project, the copies drift. Then one service's cache key quietly stops matching what the executor reads. In targetDefaults, set dependsOn: ["codegen"] on build so generated clients and schemas exist before compilation starts. Use "^build" when upstream libraries must build first.
Verifying the cache
Check each project with this routine:
- Repeat run. Run
nx run api:buildtwice. The second run should report a cache hit and restore the outputs. - Undeclared file. Edit a file outside the declared inputs, such as a README, and rerun. It should still be a hit. A miss means your inputs are too broad.
- Declared input. Edit a file inside the declared inputs, such as a schema file, and rerun. It must miss. A hit means an input is missing, and that produces stale artifacts.
When the results surprise you, run nx show project api to see the resolved inputs and outputs after defaults merge. Use nx graph to inspect the task dependencies. Then compare the task hash details between the two runs to find which input changed.
Flag names and output formats change between releases, so confirm them in the docs for your installed Nx version.
Impact on cost, engineering time and remote cache risk
Writing an executor costs more than the initial code. You also own its unit and fixture-workspace tests, compatibility fixes when Nx devkit APIs change across upgrades, and slower debugging, because the commands it runs are hidden behind an options schema. The return comes from typed, shared logic and from skipped work. Note that run-commands targets can also be cached, so the executor only adds caching value when it makes inputs and outputs more precise.
Illustrative estimate (assumed numbers, not a benchmark)
Formula: minutes saved per day = runs/day × tasks per run × task minutes × hit rate
Assume 40 CI runs per day, 5 affected tasks per run, 3 minutes per task and a 50% hit rate. That gives 40 × 5 × 3 × 0.5 = 300 CI minutes per day. Then subtract the maintenance cost. For example, assume 2 engineer-days per Nx upgrade. Plug in your own numbers.
A shared remote cache amplifies both outcomes:
| Scenario | Local cache only | Shared remote cache |
|---|---|---|
| Correct executor | Saves time for one developer | Saves time for every developer and CI run |
| Nondeterministic or under-declared inputs | One stale result on one machine | A poisoned artifact is replayed for everyone until its hash changes |
| Secrets or internal data in outputs | Stays on one disk | Readable by anyone with cache access, so the cache needs access controls |
Build, keep or retire checklist:
- Build it if three or more projects duplicate the same logic and its inputs can be declared fully.
- Keep it while its tests pass on each Nx upgrade and the cache hits it produces are verified as correct.
- Retire it if a plain
run-commandstarget with explicit inputs and outputs works just as well. - Retire it if only one person can debug it.
Alternatives to consider
Each of the following covers part of what a custom executor does. Choose the one with the least code that still makes the cache trustworthy.
run-commands with explicit inputs and outputs
If the task is a single CLI invocation with stable flags, nx:run-commands plus inputs and outputs in targetDefaults gives you the same caching with no compiled code to maintain. It becomes harder to manage once the command needs branching logic, shared option validation, or per-project path calculation. At that point the shell strings get copied across project.json files and drift apart, and a typed executor schema prevents that drift.
Inferred targets from a plugin
If the main pain is configuring the same target in fifty projects, an inference plugin may help more than an executor. The plugin reads a config file and creates targets automatically. It removes per-project boilerplate but leaves execution to an existing command. You can also combine the two: infer targets that call your executor.
package.json scripts
Nx can cache scripts defined in a project's package.json. This works well for teams that already use npm-style scripts and want caching without learning the devkit. The downside is that options stay untyped, and inputs must still be declared centrally.
The language's own build tool
For JVM, Go or Rust backends, the native toolchain may already provide incremental builds and its own cache. Wrapping it in an executor adds a second cache layer, and the two can disagree. In that case, let Nx orchestrate and run the task without Nx caching it, unless the outputs are fully declared.
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 custom executor, 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.