What a local Nx generator is and when it beats a wiki page

An Nx generator is a function that takes options, reads and writes files through Nx's virtual file tree, and updates workspace configuration. A local generator lives in an Nx plugin inside the same repository, usually under a directory such as tools/ or libs/. You run it with nx g and never publish it to a registry.

The value is that your service conventions become code instead of tribal knowledge. Those conventions include project configuration, lint and test targets, project tags, a health endpoint and a Dockerfile. When the platform team changes the standard Dockerfile, the template change goes through a pull request, gets reviewed like any other code, and lands in the same commit as any service it touches. No release has to be cut and no version has to be bumped. Nobody ends up running last quarter's template.

This pays off for platform teams and for backend teams that create services regularly, roughly when "how do I start a new service?" comes up more than once a quarter. If you create one service a year, it rarely pays for itself.

Wiki + copy-paste Maintained example service Published plugin Local plugin
Setup effort Lowest Low Highest: package, registry, release process Moderate
Drift risk High, because the docs rot silently Medium, because copies diverge after cloning Low, but consumers lag on versions Low
Review path Wiki edits, often unreviewed Normal PRs on the example only PRs in the plugin repo, then a release Normal PRs in the same repo
Upgrade coupling None None Pinned version, upgraded separately Moves with the workspace

Creating the local plugin and the anatomy of a generator

Start by installing the official plugin tooling (@nx/plugin) and using its generators to create a local plugin inside the workspace, then add a generator to that plugin. The generator names and options have changed across Nx releases, so run nx list @nx/plugin to confirm what your installed version offers before you copy any command.

The three parts of a generator

  • schema.json declares the options: types, defaults, required fields, patterns for validation, and x-prompt entries that ask for missing values interactively. Nx validates input against this file before your code runs, so a bad service name fails early with a clear message.
  • A TypeScript options interface mirrors the schema. The two are not linked automatically. When you add an option to one, add it to the other too.
  • The generator function receives a Tree and the parsed options. It returns a promise and may return a callback for follow-up work.

Why the Tree matters

The Tree is a virtual file system. Every write, rename or delete is held in memory as a pending change. Nothing reaches disk until the generator finishes successfully. That is why --dry-run is safe: Nx prints the pending changes and discards them. If you call fs directly or spawn a child process, those effects skip the Tree. They run even during a dry run, and they leave partial output when a generator fails halfway. Put work like package installs in the returned callback, which runs only after the real write.

Flow: nx g command → schema validation and prompts → generator function → in-memory Tree → formatFiles → write to disk (skipped on --dry-run) → returned callback

// tools/my-plugin/src/generators/service/generator.ts
import {
  Tree,
  formatFiles,
  generateFiles,
  installPackagesTask,
  joinPathFragments,
  names,
} from '@nx/devkit';

// Mirrors schema.json by hand. Nx does not keep the two in sync for you.
export interface ServiceGeneratorSchema {
  name: string;       // schema.json: required, pattern-validated, x-prompt
  directory?: string; // schema.json: default "services"
}

export default async function serviceGenerator(
  tree: Tree,
  options: ServiceGeneratorSchema
) {
  const { fileName } = names(options.name);
  const root = joinPathFragments(options.directory ?? 'services', fileName);

  // Stage template files in the in-memory Tree. Nothing touches disk yet.
  generateFiles(tree, joinPathFragments(__dirname, 'files'), root, {
    ...names(options.name),
    tmpl: '',
  });

  // Edits go through the Tree too, never through fs.
  tree.write(joinPathFragments(root, 'README.md'), `# ${fileName}\n`);

  // Format staged files before Nx flushes the Tree.
  await formatFiles(tree);

  // Side effects belong in the callback, which runs only after a real write.
  return () => {
    installPackagesTask(tree);
  };
}

A minimal Nx generator that stages its files and edits in the in-memory Tree, formats them with formatFiles, and defers the package install to the returned callback so that --dry-run has no side effects.

Templating a minimal backend service with generateFiles

The convention is a files/ directory beside generator.ts, rendered with generateFiles(tree, path.join(__dirname, 'files'), projectRoot, substitutions). Every file under files/ is copied into the target directory, with its contents passed through EJS: <%= className %> interpolates a value, and <% if (withDb) { %> controls which blocks are emitted. Keeping templates as real files is easier to review than building strings inside TypeScript.

Filenames are substituted as well. A segment like __name__ is replaced by the name key in the substitutions object. A __tmpl__ suffix is replaced by tmpl, which you set to an empty string. The suffix exists so that main.ts__tmpl__ is not picked up by the workspace's TypeScript compiler, linter or test runner while it sits in the plugin as a half-rendered file.

Spread the output of names(options.name) into the substitutions. From an input like billing-api, it gives you fileName, className, propertyName and constantName, so each template uses the casing it needs without the generator doing any string manipulation.

A minimal tree:

  • src/main.ts__tmpl__: bootstraps the server and mounts routes
  • src/routes/health.ts__tmpl__: liveness endpoint
  • src/config.ts__tmpl__: reads and validates configuration at startup
  • src/main.spec.ts__tmpl__: hits the health route
  • Dockerfile__tmpl__

Templates must only produce references to configuration, never values. config.ts should read process.env.DATABASE_URL and fail fast if it is missing. It should not render a URL, a port chosen per environment, or a credential. Anything rendered from a template gets committed in every service generated from it, and it is hard to find and remove later.

// tools/my-plugin/src/generators/service/generator.ts
import { Tree, formatFiles, generateFiles, joinPathFragments, names } from '@nx/devkit';
import * as path from 'path';

export interface ServiceGeneratorSchema {
  name: string;
  withDb?: boolean;
}

export default async function serviceGenerator(tree: Tree, options: ServiceGeneratorSchema) {
  // 'billing-api' -> fileName 'billing-api', className 'BillingApi',
  // propertyName 'billingApi', constantName 'BILLING_API'
  const n = names(options.name);
  const projectRoot = joinPathFragments('apps', n.fileName);

  generateFiles(tree, path.join(__dirname, 'files'), projectRoot, {
    ...n,
    name: n.fileName,              // replaces __name__ in file and directory names
    withDb: options.withDb ?? false, // drives <% if (withDb) { %> blocks
    tmpl: '',                      // strips the __tmpl__ suffix from rendered files
  });

  await formatFiles(tree);
}

// files/src/config.ts__tmpl__ renders a reference to config, never a value:
//
//   function required(key: string): string {
//     const value = process.env[key];
//     if (!value) throw new Error(`<%= className %>: missing env var ${key}`);
//     return value;
//   }
//
//   export const config = {
//     port: Number(process.env.PORT ?? 3000),
// <% if (withDb) { %>    databaseUrl: required('DATABASE_URL'),
// <% } %>  };

A generator that spreads names() output into generateFiles substitutions, sets tmpl to an empty string to strip the tmpl suffix, and renders a config template that reads environment variables at runtime instead of embedding values.

Registering the project, tags and shared config

Files on disk are only half of a service. Nx also needs to know the service exists, and that happens through addProjectConfiguration. It writes a project.json with the project name, root, sourceRoot, project type, targets and tags. Define build, serve, test and lint here, using the same executors and options your existing services use, so nx affected and CI pick the new service up immediately. If your workspace relies on inferred tasks from Nx plugins, declare only the targets that inference does not already provide. Otherwise you end up with two definitions that drift apart.

Tags matter more than they first appear. Set type:service and scope:<domain> from the generator's options, not by hand afterward. The module-boundary lint rule, @nx/enforce-module-boundaries, matches its depConstraints against these tags. That means a service that forgets to opt in is a service with no constraints. A service generated with tags is checked on its first commit, before anyone has had a chance to import another domain's internals.

Some shared files also need edits, such as path aliases in tsconfig.base.json or a CI matrix listing deployable services. Use updateJson for these. It parses the file, hands you an object to modify, and writes it back. That is safer than string replacement, and it fails loudly if the file is malformed. Make the updater idempotent: check whether the entry exists before adding it.

Finish with await formatFiles(tree). It runs Prettier over the changed files if the workspace has Prettier configured, so the generated code and the JSON you rewrote match repo formatting. Without it, the first diff a reviewer sees is whitespace noise.

import { Tree, addProjectConfiguration, updateJson, formatFiles, joinPathFragments } from '@nx/devkit';

interface Schema { name: string; domain: string; }

export default async function serviceGenerator(tree: Tree, options: Schema) {
  const root = `apps/${options.domain}/${options.name}`;

  // Register the project; tags come from options so boundary rules apply from the first commit.
  addProjectConfiguration(tree, options.name, {
    root,
    sourceRoot: joinPathFragments(root, 'src'),
    projectType: 'application',
    tags: ['type:service', `scope:${options.domain}`],
    // Mirror the executors your existing services use; omit targets your plugins already infer.
    targets: {
      build: { executor: '@nx/esbuild:esbuild', options: { main: `${root}/src/main.ts`, outputPath: `dist/${root}`, tsConfig: `${root}/tsconfig.app.json` } },
      serve: { executor: '@nx/js:node', options: { buildTarget: `${options.name}:build` } },
      test: { executor: '@nx/jest:jest', options: { jestConfig: `${root}/jest.config.ts` } },
      lint: { executor: '@nx/eslint:lint' },
    },
  });

  // Add a path alias only if it is not already there (idempotent).
  updateJson(tree, 'tsconfig.base.json', (json) => {
    json.compilerOptions ??= {};
    json.compilerOptions.paths ??= {};
    const alias = `@acme/${options.name}`;
    json.compilerOptions.paths[alias] ??= [`${root}/src/index.ts`];
    return json;
  });

  // Append to a CI matrix of deployable services without duplicating entries.
  updateJson(tree, 'ci/services.json', (json) => {
    json.services ??= [];
    if (!json.services.includes(options.name)) json.services.push(options.name);
    return json;
  });

  // Run Prettier (if configured) over everything the generator touched.
  await formatFiles(tree);
}

A generator that registers a service with addProjectConfiguration, including targets and type/scope tags, idempotently updates tsconfig.base.json and a CI matrix with updateJson, and finishes with formatFiles.

Composing official generators versus templating from scratch

Instead of templating every file yourself, your generator can call the official application generator first and then modify its output. In practice you import the application generator from the Node plugin (or your framework's plugin), await it with the same Tree and a mapped set of options, and then run your own generateFiles call to add the team-specific files: health check route, logger setup, Dockerfile, CODEOWNERS entry. After that, you patch project.json with updateProjectConfiguration to add your tags and targets. If the official generator returns a task (often a dependency install), collect it and return it from your generator so it still runs after the tree is written.

Composing gives you upstream fixes to build targets, test runner config and lint setup without any work on your side. The cost is coupling. You depend on the official generator's option names, file layout and the targets it creates, and any of these can change between Nx releases. Your overlay might then write to a path that no longer exists, or overwrite a file that has changed shape. A from-scratch template avoids that risk, but you then maintain every config file yourself.

Concern Compose official generator Template from scratch
Upstream improvements Inherited automatically Ported by hand
Sensitivity to Nx upgrades Higher: options and output can change Lower: only devkit APIs
Control over output Partial, overlay only Full
Maintenance surface Your overlay Every generated file

A reasonable default is to compose when your services follow Nx's conventions closely. Template from scratch when your build or runtime setup differs enough that you would end up overwriting most of the upstream output.

import {
  Tree,
  formatFiles,
  generateFiles,
  joinPathFragments,
  readProjectConfiguration,
  updateProjectConfiguration,
  runTasksInSerial,
  GeneratorCallback,
} from '@nx/devkit';
import { applicationGenerator } from '@nx/node';

interface ServiceSchema { name: string; team: string }

export default async function serviceGenerator(tree: Tree, opts: ServiceSchema) {
  const tasks: GeneratorCallback[] = [];
  const directory = `apps/${opts.name}`;

  // 1. Let the official generator own build, test and lint setup.
  //    Option names are an upstream contract and may change between Nx releases.
  tasks.push(await applicationGenerator(tree, { name: opts.name, directory, framework: 'express' }));

  // 2. Overlay team files: health route, logger, Dockerfile.
  generateFiles(tree, joinPathFragments(__dirname, 'files'), directory, { ...opts, tmpl: '' });

  // 3. Patch project.json with team tags and an extra target.
  const project = readProjectConfiguration(tree, opts.name);
  project.tags = [...(project.tags ?? []), 'type:service', `team:${opts.team}`];
  project.targets = {
    ...project.targets,
    'docker-build': {
      command: `docker build -f ${directory}/Dockerfile -t ${opts.name} .`,
    },
  };
  updateProjectConfiguration(tree, opts.name, project);

  await formatFiles(tree);
  // Return upstream tasks (e.g. dependency install) so they run after the tree is written.
  return runTasksInSerial(...tasks);
}

A custom Nx generator that calls the @nx/node application generator, overlays team-specific files with generateFiles, patches project.json, and returns the upstream install task.

Running, testing and evolving the generator

The fastest feedback loop is nx g <plugin>:<generator> <name> --dry-run. It runs the generator against a virtual file system and prints the files it would create or modify without writing anything to disk, so you can adjust a template and rerun in seconds. Drop the flag only when the output looks right.

Engineers will only use the one-command path if they can find it. Add a package script such as new:service that wraps the full nx g invocation, and add a short README entry that shows the command and its options. A new team member should not have to read the generator's source to learn which flags it accepts.

Unit tests on an in-memory Tree

The devkit testing utilities include createTreeWithEmptyWorkspace, which returns an in-memory Tree that represents a blank workspace. Call your generator against it, then assert on three things:

  • Files: use tree.exists and tree.read to check that the expected files were generated.
  • Project configuration: use readProjectConfiguration to check the registered project and its targets.
  • Tags: check the tags that your module boundary rules depend on.

These tests do two jobs. A template change shows up as a failing assertion that someone has to update, so it gets reviewed like any other code. After an Nx upgrade, the same tests catch upstream changes to composed generators or devkit behavior before anyone scaffolds a broken service.

Evolving conventions

A generator only affects services created after the change. Services scaffolded earlier keep the old layout. To bring them in line, write migrations for your local plugin that nx migrate can run, or write a separate update generator that rewrites existing projects in place.

When to avoid it, and the impact on cost and engineering time

Migrations fix layout, but behaviour that must stay identical across services, such as logging setup, health checks and auth middleware, belongs in shared libraries that services import. Then a fix ships once, rather than as one migration per pattern.

Keep the core small and opinionated. Every option you add for another service shape ("queue consumer?", "SQL or document store?") multiplies the combinations you must test. The templates also fill up with conditionals nobody can read. Generate the common case well and let teams add the rest by hand, or through a separate, narrower generator.

Give it an owner, usually the platform team. An unowned generator falls behind the real services and starts emitting code that fails lint or uses retired patterns. Once that happens, people go back to copying the newest service folder.

Write only through the Tree. Direct filesystem calls bypass --dry-run and can leave half-written output if a later step throws.

Skip it when you have a few services that rarely change. A well-maintained example service is cheaper and just as clear.

Illustrative estimate (assumed numbers, not a benchmark)

Break-even services per year = (B + M × 12) / (S + R)

  • B = build cost: 24 h
  • M = maintenance: 4 h/month
  • S = scaffolding time saved per service: 6 h
  • R = review churn avoided per service: 3 h

(24 + 48) / (6 + 3) = 8 services per year

Below that rate, the example service wins. Substitute your own team's figures for B, M, S and R.

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 generator, 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.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

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.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • 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.