What an Nx custom generator is and when a backend team should use one

An Nx custom generator is a function, written in TypeScript and kept in your own repository, that Nx runs to create or modify files. Nx calls it with a virtual file system (the Tree) and the options the user passed. When it lives in an Nx local plugin inside the workspace, it is part of your codebase, not a published dependency. It is reviewed, versioned and changed through the same pull requests as everything else.

For backend teams, the useful version of this is a service generator. One command produces a new API project that already has the routing setup, configuration loading, a health check endpoint, a test harness and the build, lint and test targets that CI expects. The value is less about saving keystrokes and more about consistency. Every service starts with the same logging, the same config conventions and the same CI targets, so platform changes can be applied across services without first auditing how each one was assembled.

The teams that benefit most are organizations that create services regularly in an Nx monorepo, where copying an existing service and deleting what isn't needed has started to cause drift. If you add a new service once or twice a year, a short written checklist is probably cheaper to maintain.

This article treats the generator as production code. It has tests, a clear owner and a plan for evolving the templates as your conventions change.

Creating an Nx local plugin and wiring it into the workspace

The fastest way to get a correct plugin skeleton is to let Nx generate it. Install the @nx/plugin package (nx add @nx/plugin handles installation and initial configuration), then run its plugin generator to create a library under a directory such as tools/ or libs/. Next, run the same package's generator generator against that library to add your first generator, for example one named service. Positional arguments and flag names have shifted between releases. Run nx g @nx/plugin:plugin --help and nx list @nx/plugin to see what your installed version accepts.

Generating the plugin, rather than hand-rolling it, matters because three pieces of wiring must agree for nx g my-plugin:service to resolve:

  • Name resolution. Nx has to map the plugin's import name to source in the repo. Depending on how the workspace is set up, this is either a paths entry in tsconfig.base.json or a workspace package that the package manager links.
  • The collection file. generators.json lists each generator's name, its implementation factory, its schema, and a description.
  • The manifest pointer. The plugin's package.json has a generators field pointing at generators.json, which is how Nx discovers the collection.

The resulting layout is roughly:

  • tools/my-plugin/package.json
  • tools/my-plugin/generators.json
  • tools/my-plugin/src/generators/service/ containing the generator implementation, schema.json, a typed schema definition, a spec file, and a files/ template directory

Run nx list my-plugin to confirm that Nx sees the new generator before you write any logic.

{
  "name": "my-plugin",
  "version": "0.0.1",
  "generators": "./generators.json"
}

{
  "generators": {
    "service": {
      "factory": "./src/generators/service/service",
      "schema": "./src/generators/service/schema.json",
      "description": "Scaffold a backend API service"
    }
  }
}

{
  "compilerOptions": {
    "paths": {
      "my-plugin": ["tools/my-plugin/src/index.ts"]
    }
  }
}

The three pieces of wiring that must agree for nx g my-plugin:service to resolve: the plugin's package.json generators pointer, the generators.json collection entry, and the workspace path mapping for the plugin's import name.

Anatomy of a generator: schema, options and the Tree API

A generator is a function that receives two arguments: a Tree and the parsed options. The minimal shape is an exported async function, (tree: Tree, options: ServiceGeneratorSchema), with Tree imported from @nx/devkit. It can return a callback, which Nx runs after files are written. That callback is the right place for side effects such as installing dependencies.

The options contract lives in schema.json. A minimal backend schema declares an object with a required name string, an optional port number with a default, and a directory string. Mirror it with a TypeScript interface in schema.d.ts so the function body is typed. Nx does not generate that interface for you. When the two files drift apart, the compiler still accepts the code, so keep them in sync deliberately.

Nx handles input before your code runs:

  • It validates arguments against the schema and rejects wrong types or missing required fields.
  • It applies default values.
  • It asks interactively for any property marked with x-prompt that the caller did not supply.
  • "$default": { "$source": "argv", "index": 0 } lets name be passed positionally.

The result is that your function receives complete, valid options and needs no defensive parsing.

The Tree is a virtual file system layered over the workspace:

  • tree.read(path, 'utf-8') returns file contents.
  • tree.exists and tree.children let you inspect the workspace.
  • tree.write and tree.delete record changes.

Nothing reaches disk while the generator runs. Nx collects every change and flushes them in one step once the function resolves. Because writes happen only at the end, --dry-run simply prints the pending changes and discards them. If an error is thrown partway through, nothing is written, so a failed run cannot leave a half-scaffolded service behind.

// generators/service/generator.ts
import { Tree, formatFiles, installPackagesTask, joinPathFragments, names } from '@nx/devkit';
// schema.d.ts mirrors schema.json by hand: { name: string; port?: number; directory: string }
import { ServiceGeneratorSchema } from './schema';

export default async function serviceGenerator(tree: Tree, options: ServiceGeneratorSchema) {
  // Options are already validated, prompted for and defaulted (port) by Nx.
  const { fileName, className } = names(options.name);
  const root = joinPathFragments(options.directory, fileName);
  const mainPath = joinPathFragments(root, 'src/main.ts');

  // Throwing here leaves the workspace untouched: no change has been flushed yet.
  if (tree.exists(mainPath)) {
    throw new Error(`${mainPath} already exists`);
  }

  // Recorded in the virtual tree only; nothing is written to disk yet.
  tree.write(
    mainPath,
    `import { createServer } from 'node:http';\n\n` +
      `export const ${className}Port = ${options.port};\n\n` +
      `createServer((_req, res) => res.end('ok')).listen(${className}Port);\n`
  );

  // Read an existing file, then stage an updated version of it.
  const readme = tree.read('README.md', 'utf-8') ?? '';
  tree.write('README.md', `${readme}\n- ${fileName}: backend service on port ${options.port}\n`);

  // Stage removal of an empty-directory marker that the new files make redundant.
  const keepFile = joinPathFragments(options.directory, '.gitkeep');
  if (tree.exists(keepFile)) {
    tree.delete(keepFile);
  }

  // Formats the staged files; with --dry-run Nx prints the pending changes and discards them.
  await formatFiles(tree);

  // Runs only after Nx flushes all changes to disk.
  return () => installPackagesTask(tree);
}

A minimal generator that checks, reads, writes and deletes files through the Tree API, so every change is staged and flushed in one step, and returns a callback that runs only after files reach disk.

Implementing service scaffolding: templates, project config and shared files

Start by calling the official Node application generator from @nx/node inside your own generator rather than reproducing its output. It already creates a valid project with a working build setup, and when the plugin is upgraded your scaffolding picks up the improvements. Your generator should only add what is specific to your organisation.

Layering conventions with templates

After the base app exists, call generateFiles with a files/ directory that sits next to the generator. Pass the result of names(options.name) into the substitutions so templates can use fileName (kebab case) for paths, propertyName (camel case) for variables, and className for classes. That way every service applies casing the same way. File and folder names such as __fileName__.controller.ts.template are rewritten using the same substitutions. Add tmpl: '' to remove the .template suffix. The suffix stops the editor and the TypeScript compiler from treating template files as real source.

Project configuration and tags

Read the generated config with readProjectConfiguration. Then change the build, serve, test and lint targets to match your conventions, such as a shared Dockerfile or a fixed port range. Write the result back with updateProjectConfiguration. Set tags like type:service and scope:payments at this point too. Module-boundary lint rules depend on tags, and a service without tags is not covered by them.

Shared files

Use updateJson for registries and CI matrices. Edit TypeScript route lists through the AST. String concatenation breaks on trailing commas, can add the same entry twice, and fails when someone reformats the file.

Finish with await formatFiles(tree) so the generated output passes Prettier and lint on the first commit.

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

interface Schema { name: string; scope: string; port: number; }

export default async function serviceGenerator(tree: Tree, options: Schema) {
  const n = names(options.name); // fileName, propertyName, className, constantName
  // Let the official plugin create a valid, buildable Node app first.
  await applicationGenerator(tree, { name: n.fileName, directory: `apps/${n.fileName}` });

  const project = readProjectConfiguration(tree, n.fileName);
  // Layer org conventions on top: __fileName__ in paths is substituted, tmpl: '' strips .template.
  generateFiles(tree, joinPathFragments(__dirname, 'files'), project.sourceRoot ?? project.root,
    { ...n, port: options.port, tmpl: '' });

  // Tags feed module-boundary lint rules; untagged services escape them.
  project.tags = [...new Set([...(project.tags ?? []), 'type:service', `scope:${options.scope}`])];
  // Add a conventional target that builds every service with the shared Dockerfile.
  project.targets = {
    ...project.targets,
    'docker-build': {
      executor: 'nx:run-commands',
      dependsOn: ['build'],
      options: { command: `docker build -f tools/docker/Dockerfile.service --build-arg APP=${n.fileName} -t ${n.fileName}:local .` },
    },
  };
  updateProjectConfiguration(tree, n.fileName, project);

  // Structured, idempotent edit of a shared registry instead of string concatenation.
  if (!tree.exists('tools/service-registry.json')) tree.write('tools/service-registry.json', '{"services":[]}');
  updateJson(tree, 'tools/service-registry.json', (registry) => {
    const others = registry.services.filter((s: { name: string }) => s.name !== n.fileName);
    registry.services = [...others, { name: n.fileName, port: options.port }];
    return registry;
  });

  await formatFiles(tree); // output passes Prettier on the first commit
}

A custom generator that composes the @nx/node application generator, then adds templated files, tags, a conventional target and a JSON registry entry before formatting the output.

Testing the generator and making it safe to re-run

Generator bugs are cheap to catch before anything touches disk. While you iterate, run the generator with --dry-run. Nx prints the files it would create, update or delete without writing them, so you can check paths and names in a few seconds. Dry runs show which files change but not what is inside them, so treat them as a quick feedback loop, not as verification.

Unit tests against an in-memory tree

For real assertions, call the generator function directly against createTreeWithEmptyWorkspace() from @nx/devkit/testing. Nothing reaches the filesystem, so the tests run fast and give the same result every time. Assert on three things:

  • Files: tree.exists() for each expected path, and tree.read(path, 'utf-8') to check that substituted names and ports landed in the right places.
  • Project configuration: readProjectConfiguration(tree, name) should return the targets, tags and root you expect.
  • Shared files: read the gateway route table or service registry and assert on its parsed structure, not on raw strings.

Making re-runs safe

At the start of the generator, check whether the project already exists with getProjects(tree).has(name). If it does, throw an error that names the existing project and its root. Do not silently overwrite it. Inside shared files, check for the entry before inserting it, and sort collections by key. Two engineers adding services in different orders then get identical files and no merge conflicts. A test that runs the generator twice and expects the second run to fail with that error protects this behavior.

Pinning behavior across upgrades

nx migrate can change project defaults and devkit behavior. Snapshot tests of the generated files and project configuration show those changes as test failures in the upgrade pull request, before any teammate scaffolds a service that has quietly drifted.

import { Tree, readJson, readProjectConfiguration } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { serviceGenerator } from './generator';

describe('service generator', () => {
  let tree: Tree;

  beforeEach(() => {
    // Fresh in-memory workspace per test: no disk I/O, same result every run
    tree = createTreeWithEmptyWorkspace();
  });

  it('creates files, project config and a route entry', async () => {
    await serviceGenerator(tree, { name: 'orders', port: 3101 });

    // Files exist and template substitution landed where expected
    expect(tree.exists('apps/orders/src/main.ts')).toBe(true);
    expect(tree.read('apps/orders/src/main.ts', 'utf-8')).toContain('3101');

    // Project configuration: root, tags, targets
    const project = readProjectConfiguration(tree, 'orders');
    expect(project.root).toBe('apps/orders');
    expect(project.tags).toContain('type:api');

    // Shared file: assert on parsed structure, not raw strings
    const routes = readJson(tree, 'gateway/routes.json');
    expect(routes.services.orders).toEqual({ port: 3101 });
  });

  it('keeps shared edits deterministic regardless of order', async () => {
    await serviceGenerator(tree, { name: 'users', port: 3102 });
    await serviceGenerator(tree, { name: 'billing', port: 3103 });
    const keys = Object.keys(readJson(tree, 'gateway/routes.json').services);
    expect(keys).toEqual([...keys].sort());
  });

  it('fails clearly on a second run with the same name', async () => {
    await serviceGenerator(tree, { name: 'orders', port: 3101 });
    await expect(serviceGenerator(tree, { name: 'orders', port: 3101 }))
      .rejects.toThrow(/orders.*apps\/orders/);
  });
});

A Jest suite that runs a hypothetical service generator against an in-memory Nx tree and checks generated files, project configuration, sorted shared-file entries and the error on a repeated name.

Limits, trade-offs and when to avoid a custom generator

A generator only acts on day zero. Once a service exists, it diverges from the templates as teams add endpoints, change logging, or swap libraries, and nothing pulls the template's later improvements into existing services. Fixing that means writing migrations in the same local plugin. Migrations work for mechanical changes like renaming a config key or adding a target. They stop being practical when services have been edited by hand enough that a migration has to parse and reason about code nobody wrote from the template.

Each Nx upgrade can also change devkit helpers your generator calls, so the plugin needs maintenance even when its templates have not changed.

Resist one generator with twenty flags. Options like --queue, --grpc and --no-db multiply into nested template conditionals that no one can review. Several focused generators that compose are easier to read and test.

Writes to shared registries, root CI pipelines and routing files are the riskiest part. Two parallel scaffolds produce merge conflicts, and a careless string insert can corrupt a file that every team depends on. Keep these edits minimal and structured.

Give the plugin a named owner and a normal review process. Without one, teams that disagree with the templates fork them or skip the generator entirely. Generate plain, readable boilerplate rather than clever abstractions, because people will edit the output.

Moving a local plugin into a published package for other repositories adds versioning, a release pipeline and compatibility promises. Do that only when a second repository actually needs it.

Skip the generator when:

  • Services are rare.
  • Each new service is structurally different from the last.
  • The real need is shared runtime code, which belongs in a library.

Alternatives and impact on cost and engineering time

A shared runtime library is the one alternative that removes boilerplate instead of reproducing it. The others differ mainly in what happens after day one.

Approach Consistency at creation Upgrade path for existing services Maintenance cost Monorepo fit
Copy a reference service Drifts with each copy Manual, per service Low upfront, high later Poor; copies carry stale config
Template repository Good at creation None after the fork Low Poor; built for separate repos
Generic scaffolding tool Good None built in Medium Weak; unaware of the project graph
Published Nx plugin Good, but generic Plugin migrations Low for you, less control Strong
Local Nx generator Matches your conventions Your own migration generators Medium, owned by your team Strong
Shared runtime library Enforced at runtime Version bump Medium Strong; complements generators

Time is saved in three places: repeated setup, reviewers checking wiring by hand, and CI onboarding for each new project. Cost comes from keeping templates current, writing migrations when conventions change, and upgrading the generator alongside Nx itself.

Illustrative estimate (assumed numbers, not a benchmark)

  • Assume 8 new services per year (S).
  • Assume 6 hours of setup saved per service (h).
  • Assume 2 hours of review and CI work saved per service (r).
  • Assume 30 hours per year of generator maintenance (m).

Net hours saved per year = S × (h + r) − m = 8 × 8 − 30 = 34 hours.

The break-even point is S = m / (h + r), which is about 4 services per year with these inputs. Below that, copying a reference service plus a shared library is usually cheaper.

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.