What an Nx local plugin is, and how it differs from a shared script
An Nx local plugin is an ordinary library that lives in your workspace, often under a tools/ or libs/ directory, and that Nx loads the same way it loads a published plugin. It can contribute three things: executors, which define how a target runs and declare the options it accepts; generators, which scaffold or modify code and configuration; and project and target inference, which lets Nx add targets to projects based on files it finds. You use executors and generators by referencing the plugin's package name. Inference only runs once you add the plugin to the plugins list in nx.json. Because the plugin is source code in the repo, it is versioned, reviewed, and changed atomically with the projects that depend on it. You never have to publish it to a registry.
A shared script is a shell or Node file that projects invoke through nx:run-commands. Nx can still cache it and order it in the task graph, but it sees only a command string. It does not know what the script reads, what it produces, or which options are meaningful, unless someone spells that out in each project's configuration.
Ownership usually follows this split. Individual engineers write scripts to unblock themselves. Platform teams write and maintain plugins because a plugin is a contract that many projects depend on.
The running example in this article is a common one: building a service's container image and pushing it to a registry. Most workspaces start this as a script. Some later find it worth turning into an executor.
LOCAL PLUGIN (platform team) SHARED SCRIPT (individual engineers)
+-----------------------------------+ +-----------------------------------+
| tools/container-plugin (library) | | tools/scripts/build-push.sh |
| - executors: typed options | | shell or Node file |
| - generators: scaffold config | | Nx sees only a command string |
| - inference: adds targets | | inputs/outputs/options unknown |
| versioned + reviewed in repo | | unless set in each project |
+-----------------------------------+ +-----------------------------------+
| |
executor ref: package name executor: nx:run-commands
inference: nx.json plugins list cached and ordered, but opaque
| |
v v
+--------------------------------------------------------------------------+
| apps/orders-service target: image (build container, push to registry) |
| Nx task graph: dependencies, caching, affected |
+--------------------------------------------------------------------------+
The same build-and-push image target can come from a local plugin, which Nx understands through typed executors, generators, and inference, or from a shared script that Nx runs as an opaque command through nx:run-commands.
One task, two implementations: run-commands script vs custom executor
In the script version, the target in the service's project.json uses the nx:run-commands executor (or the inline command shorthand) and calls something like tools/scripts/docker-push.sh with the image name, registry and tag as arguments. This works on day one. The cost is that the script knows nothing about the workspace. It has to be told, or has to guess, where the Dockerfile lives relative to the current directory. Any argument it needs from the project's configuration gets duplicated in the command string or re-read by parsing JSON files. Each argument is parsed by hand, so a missing --registry might surface as a malformed docker push rather than a clear error.
The custom executor version moves that logic into a local plugin. It has three parts:
- An implementation function. It receives the resolved options and an executor context.
- A JSON schema. It declares the option names, types, defaults and required fields.
- A manifest. It maps a name like
docker-pushto the implementation and schema.
The service's target then references the executor by the plugin's name.
The context is the main gain. It carries the workspace root, the current project's name and the project graph's configuration. As a result, the executor can resolve the project root and build an absolute Dockerfile path without fragile relative paths. It never re-parses config files.
The schema replaces ad hoc argv handling. Nx validates options before your code runs and applies the defaults. The schema also lets the CLI and editor tooling show the available options.
File names, manifest fields, and how local plugins are registered (in nx.json or through workspace package resolution) have changed across Nx releases. Check them against the documentation for your Nx version.
// tools/docker/src/executors/docker-push/executor.ts
// Options arrive already validated and defaulted by Nx from schema.json,
// so there is no argv parsing here.
import { ExecutorContext } from '@nx/devkit';
import { execFileSync } from 'child_process';
import * as path from 'path';
export interface DockerPushOptions {
registry: string; // marked "required" in schema.json
tag: string; // schema.json default: "latest"
dockerfile: string; // schema.json default: "Dockerfile" (relative to project root)
}
export default async function dockerPush(
options: DockerPushOptions,
context: ExecutorContext
): Promise<{ success: boolean }> {
// Resolve the project from the context instead of guessing paths or re-reading project.json.
const projectName = context.projectName!;
const projectRoot = context.projectsConfigurations!.projects[projectName].root;
const absRoot = path.join(context.root, projectRoot);
const image = `${options.registry}/${projectName}:${options.tag}`;
try {
// Build from the project's own directory using an absolute Dockerfile path.
execFileSync(
'docker',
['build', '-f', path.join(absRoot, options.dockerfile), '-t', image, absRoot],
{ stdio: 'inherit' }
);
execFileSync('docker', ['push', image], { stdio: 'inherit' });
return { success: true };
} catch {
return { success: false };
}
}
This sketch of a custom executor gets the workspace root and project root from the ExecutorContext and takes schema-validated options instead of parsing argv. Field names such as projectsConfigurations vary across Nx versions, so check them against your version's documentation.
Caching, inputs and outputs: where centralization helps and hurts
Caching does not depend on which approach you choose. Nx caches a target when the target declares what it reads and what it writes. A run-commands target that wraps a shell script is cached the same way as a custom executor, provided its configuration is correct. A typical declaration sets inputs to ["production", "^production"] and outputs to ["{projectRoot}/dist"]. This tells Nx to hash the project's production files and those of its dependencies, then restore the build directory on a cache hit.
The difference is who writes that declaration. With shared scripts, every project.json carries its own copy. Each copy can drift: someone forgets a config file in inputs, or points outputs at the wrong folder. Nx cannot detect the mistake. It caches whatever the configuration says. A local plugin that infers targets writes the declaration once, in code, and every matching project gets the same inputs and outputs.
That consistency also concentrates the risk. Suppose a plugin omits a file the tool actually reads, such as a shared lint config or a codegen schema. Every project that uses the plugin will then return stale cache hits after that file changes, all at once. To guard against this:
- Run
nx show project <name>to inspect the resolved inputs and outputs, rather than trusting what the plugin is supposed to produce. - Write unit tests for the plugin that assert the generated inputs and outputs.
- After changing the plugin, compare a cached run with a run using
--skip-nx-cache. Runnx resetif the results differ.
{
"name": "orders-api",
"targets": {
"build": {
"executor": "nx:run-commands",
"options": {
"command": "bash tools/scripts/build.sh apps/orders-api"
},
"cache": true,
"inputs": [
"production",
"^production",
"{workspaceRoot}/tools/scripts/build.sh",
"{workspaceRoot}/tools/codegen/schema.graphql"
],
"outputs": ["{projectRoot}/dist"]
}
}
}
A project.json build target wrapping a shared script, with inputs that include the script and a shared codegen schema it reads, and an outputs path that Nx restores on a cache hit.
Generators and inferred tasks: the main payoff at scale
Caching gets the attention, but the larger return from an Nx local plugin is control over what a project looks like on the day it is created. A generator can scaffold a new service with its build, test and lint targets, a Dockerfile built from the team's base image, and the CI job definitions in one step. A shared script can copy files too, but it has no view of the workspace graph, cannot update project.json or tsconfig paths safely, and usually goes stale while people keep copying templates by hand. A generator runs through Nx's virtual file tree, so you can preview its changes with a dry run and cover it with tests.
Inferred tasks go further. Instead of writing target config into each project, the plugin looks at the files a project contains and adds standard targets for it. For example, a Dockerfile gets a docker-build target, and a migrations directory gets a migrate target. New services pick up the conventions without anyone copying configuration. The inference API has changed across Nx releases, so check the documentation for your installed version before you build on it.
Inference has a cost. A target that appears or disappears because a file exists is hard to discover. An on-call engineer reading project.json will not see it. Teach the team to run nx show project <name> or open the project details view to see the resolved targets, their inputs and the commands that will actually run.
# 1. Scaffold a new service with the local plugin's generator.
# --dry-run prints the files the generator would create or modify
# (project.json, Dockerfile, CI job, tsconfig paths) without writing them.
npx nx g @acme/workspace-plugin:service payments-api --dry-run
# Once the preview looks right, run it for real.
npx nx g @acme/workspace-plugin:service payments-api
# 2. Check what Nx actually resolved for the project. Inferred targets
# (e.g. docker-build from a Dockerfile) do not appear in project.json,
# so read the resolved configuration instead.
npx nx show project payments-api
# Machine-readable form: list target names, including inferred ones.
npx nx show project payments-api --json | jq '.targets | keys'
# Inspect one target's command, inputs and outputs before running it.
npx nx show project payments-api --json | jq '.targets["docker-build"]'
# Open the project details view in a browser for the same information.
npx nx show project payments-api --web
Running a local plugin generator with a dry run, then using nx show project to check which targets, including inferred ones, Nx resolved for the new service.
When to keep a script and when to promote it to a local plugin
The visibility cost of inferred targets is one reason not to promote everything. The decision comes down to how often a task runs, how many projects it touches, and who will maintain it.
| Signal | Keep a shared script | Promote to an Nx local plugin |
|---|---|---|
| Frequency | Runs rarely: release prep, one-off data fixes | Runs on most builds or PRs |
| Scope | Workspace-wide, with no per-project inputs | Runs per project, with project-specific options |
| Caching | Results are cheap or never reused | Cache hits matter, so inputs and outputs must be declared correctly |
| Duplication | Referenced from one place | The same run-commands block is copied into many project.json files |
| New projects | Opt-in is fine | Every new project must get the target automatically |
| Ownership | One person owns it | A team owns it and reviews changes |
| Execution context | Must also run outside Nx, in plain CI steps or other tools | Always invoked through Nx |
Per-project execution and cache correctness are the strongest signals. A copied run-commands target with hand-maintained inputs drifts silently, and a stale cache entry ships wrong output. A plugin defines inputs and outputs once, so they are correct everywhere.
Avoid a plugin in three cases. First, small helpers: a twenty-line formatter wrapper does not justify a TypeScript package, its tests, and an API surface. Second, automation that other systems call directly: a deploy script that your CI platform or a container entrypoint runs without Nx should stay a plain script. Nx can still wrap it as a target. Third, a team with no capacity to maintain plugin code: plugin APIs change across Nx upgrades, and an unowned plugin will block those upgrades.
Impact on cost and engineering time
Neither option is free. The costs land in different places and show up at different times.
| Cost | Local plugin | Shared script |
|---|---|---|
| Upfront work | TypeScript that Nx must transpile and load, plus tests built on devkit testing utilities | A short shell or Node file |
| Blast radius of a mistake | A registration or build error can break project graph computation for the whole workspace | Usually one target in one project |
| Ongoing upkeep | API changes across Nx major versions, and tight coupling to Nx | Low per script, but target config is copied into every project and drifts |
| Transparency | Logic sits behind an executor or inferred target, so non-platform engineers must read plugin source | Readable where it runs |
| How failures surface | Loudly and early | Often quietly. An untested script can exit 0 or write outputs to the wrong path, and the cache then stores a bad result |
Plugins save time in three ways. Engineers hand-edit fewer project.json files. New services start out consistent. Cache inputs and outputs are defined once instead of in every project.
Illustrative estimate (assumed numbers, not a benchmark)
Net hours saved per year = (S × E) + (C × F) − B − U
- S = new services per year (assume 20)
- E = hours spent hand-wiring each one (assume 1.5)
- C = cache misconfiguration incidents avoided (assume 6)
- F = hours to diagnose each incident (assume 4)
- B = build effort (assume 40, first year only)
- U = yearly upkeep (assume 16)
First year: 30 + 24 − 40 − 16 = −2 hours. Each later year: 54 − 16 = 38 hours.
With these inputs, the plugin only pays off if someone keeps owning it after the first year.
An incremental migration path from script to plugin
That ownership cost is why the move should happen in steps, each of which can be merged and reverted on its own.
Step 1: Wrap, don't rewrite
Create a thin executor that imports the existing script module and calls its main function. Keep the script's own entry point, so CI jobs and engineers who run it directly keep working. Point one project's target at the new executor and compare it with the old run-commands target. Both should produce the same outputs and exit codes. If they differ, the problem is in the wrapper, because the logic hasn't changed.
Step 2: Replace argv parsing with a schema
Add an options schema and move defaults and validation into it. Keep argv parsing in the standalone path for now. Have both paths build the same options object before calling the shared function, then unit test that function directly. Once nothing calls the script by hand, delete the argv code.
Step 3: Make it automatic
Add a generator that writes the target into new projects, or an inference plugin that creates it from a marker file. Inference has a wider blast radius because it changes every matching project at once. Register it in nx.json in its own commit. If registration breaks graph creation, revert that commit and the explicit targets still work.
Before merging
Run nx show project <name> on a few representative projects and check the resolved target, its inputs and its outputs. Then run the target twice to confirm the second run is a cache hit.
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 local plugin, 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.