What an Nx custom generator is and why it beats a template repo

An Nx custom generator is a function that runs against your live workspace through a virtual file system called the Tree. It reads existing files, creates new ones and edits shared configuration in memory, then writes everything to disk in one step. Because nothing touches disk until the end, you can preview the full set of changes with a dry run before committing to them.

A new backend service needs more than its own files. It has to be registered as an Nx project, given tags so module-boundary rules apply, given targets for build, serve, test and lint, and added as a path alias in the root TypeScript config so other projects can import it. A template repo only produces files. It cannot edit a workspace that already exists. Copying the last service carries over its mistakes and leaves you to fix names and paths by hand. A generator handles both the new files and the edits to shared config.

Approach Creates files Registers project, tags, targets Updates shared config Stays consistent over time
Template repo Yes No No Drifts from the monorepo
Copy the last service Yes Manually Manually Copies its drift and bugs
Generic CLI scaffolder Yes Partially Rarely Doesn't know your conventions
Custom generator Yes Yes Yes Versioned with the workspace

The platform team that owns backend templates should build the generator. It pays off once several services share a stack, because each new service then starts from conventions the team has already encoded.

How it works: local plugins, generators.json and the virtual Tree

A generator lives in a local Nx plugin, which is an ordinary library in the workspace (often under tools/ or libs/) that Nx resolves like any published plugin. Because it is a project, it gets the same lint, test and build targets as your services, and changes to it go through normal code review. Older workspaces may still use workspace generators, a special folder run through a dedicated command. Nx has superseded that approach with local plugins and migrates existing ones toward them, so move off it rather than building on it.

Inside the plugin, generators.json is the registry. Each entry maps a generator name, such as service, to a factory (the implementation file and export) and a schema (its schema.json). The plugin's package.json points at this file. When someone runs the generator, Nx uses that mapping to find the code to load and to validate and prompt for options before any code runs.

When the generator runs, it does not touch the filesystem directly. It receives a Tree, which is an in-memory view of the workspace. Every create, update and delete is recorded there, and Nx writes the result to disk only after the generator returns without error. A failure halfway through leaves the repository unchanged, and --dry-run can report the planned changes without writing them. The generator can also return a callback. Nx runs it after the write, which suits side effects such as installing dependencies.

nx g <plugin>:service --name=orders
        │
        ▼
generators.json ──► factory + schema.json (validate, prompt)
        │
        ▼
generator(tree, options) ──► in-memory Tree (create/update/delete)
        │
        ▼
flush changes to disk   (skipped on error or --dry-run)
        │
        ▼
optional callback (e.g. install packages)
// tools/my-plugin/src/generators/service/generator.ts
// Registered in generators.json as "service" -> factory: ./src/generators/service/generator
import {
  Tree,
  formatFiles,
  generateFiles,
  installPackagesTask,
  joinPathFragments,
  names,
} from '@nx/devkit';

export interface ServiceGeneratorSchema {
  name: string; // validated against schema.json before this code runs
  directory?: string;
}

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

  // Throwing here aborts the run: nothing recorded in the Tree reaches disk.
  if (tree.exists(root)) {
    throw new Error(`Service already exists at ${root}`);
  }

  // Writes go to the in-memory Tree only; --dry-run just reports them.
  generateFiles(tree, joinPathFragments(__dirname, 'files'), root, {
    ...options,
    fileName,
    className,
    tmpl: '', // strips the __tmpl__ suffix from template file names
  });

  await formatFiles(tree);

  // Nx runs this callback after the Tree has been flushed to disk.
  return () => installPackagesTask(tree);
}

A minimal local-plugin generator: it records all changes on the in-memory Tree, aborts cleanly by throwing, and returns a callback that Nx runs after the changes are written to disk.

Designing schema.json as the contract for nx scaffolding

The schema is the generator's public API. Teams will run it hundreds of times, and every option you expose is a decision you are asking each engineer to make. Make the decisions in the generator and leave out the options.

Required options

A backend service generator needs only two required inputs: name and directory. Put both in the required array so validation fails before any file is generated. Add a pattern to name, such as lowercase kebab-case. A bad name then fails during validation instead of producing a broken import path that someone finds after merge.

Enums for constrained choices

Model the transport (http, grpc) and the database driver (postgres, none) as enum values. Avoid free-text strings for these. An enum documents the options the platform team supports, rejects typos, and maps each value to exactly one template branch.

Defaults and prompts

Give each optional field a default that matches the house standard. In practice this means http and postgres, so the common case needs only a name and a directory. Add x-prompt to the required fields and to the enums. Interactive users then get a question or a selection list. CI and scripted runs pass flags and never see a prompt.

Why small matters

Options multiply. Two transports times two drivers gives four combinations to render and test. Add a logging flag and an auth flag and you have sixteen. Each new option looks cheap when you add it, and the combined cost arrives later. A generator with twenty options becomes a mini framework with its own bugs and its own learning curve, and it stops producing consistent services. If a variation is rare, let the team that needs it edit the generated code.

{
  "$id": "BackendService",
  "title": "Backend service generator",
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "description": "Service name in lowercase kebab-case, e.g. billing-api.",
      "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
      "$default": { "$source": "argv", "index": 0 },
      "x-prompt": "What is the service name (kebab-case)?"
    },
    "directory": {
      "type": "string",
      "description": "Directory the service is created in, e.g. services/billing-api.",
      "x-prompt": "Which directory should the service be created in?"
    },
    "transport": {
      "type": "string",
      "description": "Transport layer. Each value maps to one template branch.",
      "enum": ["http", "grpc"],
      "default": "http",
      "x-prompt": {
        "message": "Which transport should the service use?",
        "type": "list",
        "items": [
          { "value": "http", "label": "HTTP (house standard)" },
          { "value": "grpc", "label": "gRPC" }
        ]
      }
    },
    "database": {
      "type": "string",
      "description": "Database driver. Use none for stateless services.",
      "enum": ["postgres", "none"],
      "default": "postgres",
      "x-prompt": "Which database driver should the service use?"
    }
  },
  "required": ["name", "directory"]
}

A four-option schema.json: name and directory are required, name is validated by a kebab-case pattern, transport and database are enums with house-standard defaults, and x-prompt asks interactive users for any value not passed as a flag.

Implementing the generator with @nx/devkit

The generator function receives the virtual Tree and the validated options, and works in four steps.

1. Normalize the inputs

Pass the raw service name through names() from @nx/devkit. It returns fileName (kebab-case), className (PascalCase), propertyName (camelCase) and constantName. Use fileName for directories, image names and log fields, and className for types. Computing every casing in one place means that billing-api, BillingApi and billingApi always refer to the same service.

2. Register the project

Call addProjectConfiguration() with the project root, source root, projectType: 'application' and the build, serve, test and lint targets. Add the tags type:service and scope:<domain> here as well. If the tags are set at creation time, the module boundary lint rule applies from the first commit, so a new service cannot import another domain's internals before anyone adds the tags by hand.

3. Render the templates

generateFiles() copies a template directory into the tree and renders each file as EJS using a substitution object. A path segment such as __name__ is replaced by the matching key. The usual convention is to give template files a __tmpl__ suffix and pass tmpl: '', which removes the suffix so the templates are not picked up by linting or compilation in the generator's own project.

4. Keep the template thin

The template should contain:

  • bootstrap
  • validated environment config
  • health and readiness endpoints
  • a shared error response shape
  • structured logging
  • a sample route with its test
  • a Dockerfile or build target

Most of these should be imports from shared libraries, not copied code. The error shape, logger and config loader should live in libraries, and the service should only wire them together. A fix to a library then reaches every service, while a copy stays wherever it was generated.

Finish by calling formatFiles(tree).

import {
  Tree,
  names,
  addProjectConfiguration,
  generateFiles,
  joinPathFragments,
  formatFiles,
} from '@nx/devkit';
import * as path from 'path';

interface ServiceGeneratorSchema {
  name: string;   // e.g. 'billing api'
  domain: string; // e.g. 'billing'
}

export default async function serviceGenerator(tree: Tree, options: ServiceGeneratorSchema) {
  // 1. Derive every casing in one place so paths, types and log fields stay in sync.
  const n = names(options.name); // fileName: 'billing-api', className: 'BillingApi'
  const projectRoot = joinPathFragments('apps', n.fileName);
  const src = joinPathFragments(projectRoot, 'src');

  // 2. Register the project with its tags, so module boundary rules apply from the first commit.
  addProjectConfiguration(tree, n.fileName, {
    root: projectRoot,
    sourceRoot: src,
    projectType: 'application',
    tags: ['type:service', `scope:${options.domain}`],
    targets: {
      build: { executor: '@nx/esbuild:esbuild', options: { main: `${src}/main.ts`, outputPath: `dist/${projectRoot}`, tsConfig: `${projectRoot}/tsconfig.app.json` } },
      serve: { executor: '@nx/js:node', options: { buildTarget: `${n.fileName}:build` } },
      test: { executor: '@nx/jest:jest', options: { jestConfig: `${projectRoot}/jest.config.ts` } },
      lint: { executor: '@nx/eslint:lint' },
    },
  });

  // 3. Render the EJS templates: __name__ in file paths becomes 'billing-api', and tmpl: '' strips the __tmpl__ suffix.
  //    The templates import the logger, config loader and error shape from shared libs instead of copying them.
  generateFiles(tree, path.join(__dirname, 'files'), projectRoot, {
    ...n,
    name: n.fileName,
    domain: options.domain,
    tmpl: '',
  });

  await formatFiles(tree);
}

A minimal service generator that normalizes the name with names(), registers the project with type:service and scope: tags through addProjectConfiguration(), and renders EJS templates with generateFiles().

Editing shared files safely and composing generators

Root configuration is where generators most often go wrong, so treat every edit there as something that may run more than once. To register the new service's contracts import alias, use updateJson() on tsconfig.base.json instead of reading and rewriting the file yourself. In the updater, check whether compilerOptions.paths already contains the alias before you add it. If the entry exists and points to the same location, leave it alone. If it points somewhere else, throw an error that names the conflict rather than overwriting it. Running the generator a second time should then produce no diff. That is the easiest property to test, and it is the first one to break.

Don't re-implement scaffolding that already exists. If each service needs a contracts package, call the library generator from your plugin with the name, directory and tags filled in. This way you inherit its project configuration, lint setup and future fixes, and you skip building a weaker copy of it.

Finish with formatFiles(tree) so the output passes the workspace formatter check without a follow-up commit.

Dependency installation belongs in a returned callback, for example the task from addDependenciesToPackageJson, and not in a call made during generation. The Tree is virtual until Nx commits it. Installing inline would run against a package.json that hasn't been written yet, and it would also run during --dry-run.

Avoid regex or string splicing on CI pipelines and other root files with no structured API. Comments, anchors and hand edits make those patches fragile, and a bad match corrupts the file silently. When a change needs human judgment, print a clear manual step at the end of the run instead.

import {
  Tree, GeneratorCallback, updateJson, formatFiles, logger,
  addDependenciesToPackageJson, runTasksInSerial, getProjects,
} from '@nx/devkit';
import { libraryGenerator } from '@nx/js';

interface Schema { name: string }

export default async function serviceGenerator(tree: Tree, opts: Schema): Promise<GeneratorCallback> {
  const tasks: GeneratorCallback[] = [];
  const libName = `${opts.name}-contracts`;
  const libDir = `libs/contracts/${opts.name}`;
  const alias = `@acme/${libName}`;
  const target = `${libDir}/src/index.ts`;

  // Reuse the official library generator; skip on re-runs so it doesn't fail on an existing project.
  if (!getProjects(tree).has(libName)) {
    tasks.push(await libraryGenerator(tree, { name: libName, directory: libDir, tags: 'type:contracts' }));
  }

  // Idempotent alias edit: add if missing, no-op if identical, refuse to overwrite a conflict.
  updateJson(tree, 'tsconfig.base.json', (json) => {
    const paths = (json.compilerOptions ??= {}).paths ??= {};
    const existing: string[] | undefined = paths[alias];
    if (existing && !(existing.length === 1 && existing[0] === target)) {
      throw new Error(`Alias ${alias} already maps to ${existing.join(', ')}, expected ${target}`);
    }
    paths[alias] = [target];
    return json;
  });

  // Only edits package.json in the Tree; the returned task installs after Nx writes files (never on --dry-run).
  tasks.push(addDependenciesToPackageJson(tree, { zod: '^3.0.0' }, {}));

  await formatFiles(tree);

  // CI config has no structured API here, so ask a human instead of regex-patching it.
  logger.info(`Manual step: add ${opts.name} to the deploy matrix in your CI pipeline file.`);

  return runTasksInSerial(...tasks);
}

A generator that composes the @nx/js library generator, adds a tsconfig path alias idempotently with updateJson(), formats output, defers dependency installation to a returned callback, and prints a manual CI step instead of string-editing the pipeline.

Testing the generator and rolling it out

Unit tests against an in-memory workspace

@nx/devkit/testing exports createTreeWithEmptyWorkspace(), which returns a virtual Tree with only the minimal workspace files. Call your generator function against it with a fixed set of options, then assert on the results. Use tree.exists() to check that the expected files were created. Use readProjectConfiguration(tree, name) to confirm the project root, targets and tags. Tags deserve their own assertion because module-boundary lint rules depend on them, and a missing tag fails silently. These tests never touch disk, so they run in milliseconds and can cover every schema branch.

Snapshot tests are useful for long rendered templates, such as a controller or a Dockerfile, where asserting line by line is tedious. A snapshot only catches drift, though. When it changes, someone has to read the diff and confirm the change was intended. Do not regenerate snapshots by reflex.

For manual checks, nx g <plugin>:<generator> --dry-run lists the files that would be created or modified without writing anything.

You can also generate a service in CI and run its build and lint targets. This catches template errors that file-level assertions miss, such as a broken import. It also adds a full compile to every pipeline run, so consider running it only when the plugin's files change.

Rollout

Document one command that creates a service, and treat it as the only supported path. Run the generator tests in CI. Version template changes like any other API change: record what changed in a changelog and announce it to the teams that own services, because existing services will not pick up the change automatically.

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

Generation is a one-time copy. After a service is scaffolded, a template fix never reaches it on its own. You need either a shared library that services import, or separate migration logic that rewrites existing code. Several other costs also add up:

  • Option creep. Every new option doubles the number of possible outputs, so a generator with many options becomes hard to maintain and test.
  • Unchecked templates. EJS templates are not type-checked or linted, so mistakes surface only when the generated output is compiled or tested.
  • Upgrade breakage. Devkit APIs change between Nx releases, so upgrades can break the generator.
  • Structure without behavior. A generator enforces structure, not behavior. It cannot stop someone from removing the auth middleware a week later.
  • Ownership. All of this needs a long-term owner.
Situation Build a generator?
Many services created each year, stable stack Yes
Only a few services No; document a checklist
Stack still changing quickly No; templates go stale faster than they pay off
No team to own it No; an abandoned generator produces outdated code

Illustrative estimate (assumed numbers, not a benchmark)

Assumed inputs:

  • Setup: S = 40 hours
  • Yearly maintenance (testing Nx upgrades, template changes): M = 48 hours
  • Time saved per new service versus manual setup: T = 5 hours
  • Review effort avoided per service, because reviewers no longer check boilerplate: R = 2 hours

Break-even services per year = (S + M) / (T + R) = 88 / 7 ≈ 13 in the first year. In later years, setup no longer counts, so break-even is M / (T + R) = 48 / 7 ≈ 7 services.

If your team creates fewer services than that, the generator costs more time than it saves. Replace the assumed values with your own figures.

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.