What an Nx custom generator is and why NestJS services drift without one

An Nx custom generator is a function that receives a virtual file tree and modifies it. It creates files, edits existing ones, and registers projects in workspace configuration. Nothing touches disk until the generator finishes, so the result can be previewed with --dry-run and reviewed as an ordinary diff. The change is repeatable because it is code, and it is reviewable because its output lands in a pull request like any other change.

Without a generator, NestJS services drift in predictable ways. A team copies the newest service and inherits its leftover experiments. Another team follows a README that was written for an older layout and skips the step that wires up health checks. Six months later, each service configures logging, validation pipes, and environment loading slightly differently. Shared tooling then needs a special case for every service.

The investment pays off for platform teams and for organisations running several NestJS services in one monorepo, roughly once new services appear more than a few times a year. With one or two stable services, it rarely earns back the effort.

Approach Stays current Edits existing workspace config Main weakness
README checklist Only if someone updates it Manually Steps get skipped or misread
Template repo Diverges after copy No Copies are frozen at clone time
Forked upstream generator Requires manual merges Yes You own every upstream change
Shared runtime library Yes, via upgrades No Covers behaviour, not file structure
Custom generator Yes, versioned with the repo Yes Needs tests and maintenance

Generators and shared libraries complement each other. The library holds the runtime behaviour, and the generator wires the library into each new service.

Setting up a local Nx plugin and its generators collection

A local plugin is an ordinary library in the workspace that also exposes a generators collection. The library's package.json points to a generators.json file. That file maps each generator name to its implementation and its schema.json. Each generator gets its own folder, which holds the implementation, the schema, a TypeScript options type and a files/ directory of templates.

tools/nest-service/
├── package.json          ──► "generators": "./generators.json"
├── generators.json       ──► service: { factory, schema }
└── src/generators/service/
    ├── generator.ts      ──┐
    ├── schema.json         │  1. applicationGenerator (@nx/nest) ─┐
    ├── schema.d.ts         │  2. generateFiles(files/)            ├─► Tree (in memory)
    └── files/              │  3. updateJson / project config      ┘        │
                            └────────────────────────────────────►  flushed to disk

You don't need to write this boilerplate by hand. Add the @nx/plugin package to the workspace, then run its plugin generator to create the library. Run its generator generator to add the first entry to the collection and wire up generators.json. The second step also creates a spec file, so the generator is testable from its first commit.

This setup replaces the older workspace generators, which were loose scripts under tools/ that a dedicated command ran. Those scripts had no project configuration of their own, so Nx could not lint, test or cache them like other code. A local plugin is a real project. It shows up in the project graph, gets its own test and lint targets, and can later be published if other repositories need the same conventions. You invoke it the same way as any installed plugin, using the library's import path followed by the generator name.

Every step in the diagram writes to the Tree, an in-memory virtual file system. Nothing reaches disk until the generator finishes. This is why a dry run can show the full set of changes without writing them.

# 1. Add the Nx plugin tooling as a dev dependency.
npm install -D @nx/plugin

# 2. Create the local plugin library under tools/.
#    --importPath sets the name you will invoke it by later.
npx nx g @nx/plugin:plugin tools/nest-service --importPath=@acme/nest-service

# 3. Add the first generator to the collection.
#    This creates src/generators/service/ with generator.ts, schema.json,
#    schema.d.ts, files/ and a spec file. It also registers the entry
#    in generators.json.
npx nx g @nx/plugin:generator tools/nest-service/src/generators/service --name=service

# 4. The plugin is a real project, so the usual targets work on it.
npx nx test nest-service
npx nx lint nest-service

# 5. Invoke it like any installed plugin: <import path>:<generator name>.
#    Use --dry-run first to preview the Tree changes without writing to disk.
npx nx g @acme/nest-service:service orders --dry-run
npx nx g @acme/nest-service:service orders

Creating a local Nx plugin and its first generator with @nx/plugin's own generators, then testing it and invoking it by import path, with a dry run first.

Defining inputs with schema.json and a normalized options interface

The Tree only receives what your generator tells it to write, so the inputs need to be pinned down first. In an Nx custom generator they come from two files that must stay in agreement: schema.json, which Nx reads at the command line, and a TypeScript interface, which your implementation reads.

schema.json: the contract with the caller

Declare four properties:

  • name (string, required). Map it to the first positional argument with "$default": { "$source": "argv", "index": 0 } so engineers can type the service name without a flag.
  • directory (string). Give it an x-prompt asking where the service should live. Platform teams usually want services grouped under one folder such as apps/services, and the prompt encourages that layout.
  • port (number, default 3000). A default keeps local runs predictable, and teams can override it to avoid collisions.
  • includeDatabase (boolean, default false). Add a yes/no x-prompt so the choice is explicit rather than forgotten.

Nx validates types and fills in defaults before your code runs. Your implementation never has to guess whether port arrived as a string.

The options interface and normalizeOptions

Write a Schema interface that mirrors these properties exactly. Then add a NormalizedSchema that extends it with derived fields: projectName, fileName, className, and projectRoot.

One normalizeOptions function produces all of them. It calls names() from @nx/devkit on the raw name and joins directory with the file name to build projectRoot.

Every later step reads only from this object. That matters because file paths, class names, and project configuration then share one source. A casing mismatch, such as an orders-api folder holding an OrderApiModule, cannot creep in through a second, slightly different string transformation.

// schema.d.ts + normalize.ts: the raw options mirror schema.json exactly,
// and every derived value is computed once in normalizeOptions.
import { joinPathFragments, names } from '@nx/devkit';

// Must match the properties declared in schema.json.
export interface Schema {
  name: string;             // positional arg: $source argv, index 0
  directory?: string;       // asked via x-prompt
  port: number;             // default 3000 in schema.json
  includeDatabase: boolean; // default false, yes/no x-prompt
}

// Derived fields the rest of the generator reads from.
export interface NormalizedSchema extends Schema {
  projectName: string; // e.g. "orders-api"
  fileName: string;    // e.g. "orders-api"
  className: string;   // e.g. "OrdersApi" -> OrdersApiModule
  projectRoot: string; // e.g. "apps/services/orders-api"
}

export function normalizeOptions(options: Schema): NormalizedSchema {
  // names() derives every casing from one input, so folders and
  // class names cannot drift apart.
  const { fileName, className } = names(options.name);

  // Fall back to the team's conventional folder if no directory was given.
  const directory = options.directory ?? 'apps/services';

  return {
    ...options,
    directory,
    projectName: fileName,
    fileName,
    className,
    projectRoot: joinPathFragments(directory, fileName),
  };
}

A Schema interface that mirrors schema.json, extended by NormalizedSchema, with one normalizeOptions function that uses names() and joinPathFragments() from @nx/devkit to derive the project name, file name, class name, and project root in one place.

How to implement it: compose the Nest generator, then layer on your conventions

With options normalized, the generator body runs in two phases. First, await the application generator from @nx/nest, passing the normalized name and directory. That produces the project configuration, build and serve targets, and the base Nest bootstrap in the form the Nx team maintains. Your generator should not re-create any of it.

Second, call generateFiles from @nx/devkit with a templates directory and a substitutions object. That object holds the output of names() (such as className, fileName and constantName) plus the template marker. Substitution runs in two places. EJS tags replace values inside file contents, and placeholders such as __fileName__ rename paths, so a src/__fileName__.config.ts__tmpl__ template becomes src/orders-api.config.ts once you pass tmpl: ''. Use the templates for the organization layer:

  • A health-check module
  • A config module that validates environment variables at startup, so a missing DATABASE_URL stops the boot instead of surfacing later as a runtime error
  • Structured logging setup
  • A Dockerfile

Finish with formatFiles so the output matches the repository's formatter.

Keep each template to a few lines. The health module and logger should import from a shared workspace library and register its providers rather than contain the implementation. If you copy auth guards or logging code into every service, a bug fix becomes fifty pull requests. If services import that code from a library, the fix lands in one place.

Composing the Nest generator couples you to its output. A plugin upgrade can rename files or change project configuration that your templates assume. Pin the Nest plugin version and keep it in step with the rest of your Nx packages. Cover the combined output of both generators with tests, so an upgrade fails in CI before it reaches a new service.

import {
  Tree,
  formatFiles,
  generateFiles,
  joinPathFragments,
  names,
} from '@nx/devkit';
import { applicationGenerator } from '@nx/nest';

interface ServiceGeneratorSchema {
  name: string; // e.g. 'orders-api'
  directory?: string; // e.g. 'apps/orders-api'
}

export default async function serviceGenerator(
  tree: Tree,
  options: ServiceGeneratorSchema
) {
  const name = names(options.name);
  const projectRoot = options.directory ?? `apps/${name.fileName}`;

  // Phase 1: let the official Nest generator own project config,
  // build/serve targets and the base bootstrap. Do not re-create them.
  await applicationGenerator(tree, {
    name: name.fileName,
    directory: projectRoot,
  });

  // Phase 2: layer organization files on top. names() supplies
  // className, fileName, constantName, etc. for EJS tags and __fileName__ paths.
  // tmpl: '' strips the __tmpl__ suffix from template file names.
  generateFiles(tree, joinPathFragments(__dirname, 'files'), projectRoot, {
    ...name,
    tmpl: '',
  });

  // Match the repository's formatter so generated code passes lint/format checks.
  await formatFiles(tree);
}

A custom Nx generator that awaits the @nx/nest application generator, then adds organization templates with generateFiles and names() substitutions before running formatFiles.

Editing existing files safely through the Tree and enforcing architecture with tags

Once the Nest generator has produced its files, your generator starts editing what already exists. Make every one of those edits through the devkit, not through ad-hoc file writes.

For project metadata, read the project with readProjectConfiguration, add tags such as scope:payments and type:api, attach your standard targets (container build, contract tests), and save it with updateProjectConfiguration. For other JSON files, such as a tsconfig path mapping, use updateJson. These helpers parse the file, apply your change to an object, and serialize it again, so you never splice text inside a brace.

TypeScript needs the same care. To register a new module in an existing AppModule, parse the file with the TypeScript compiler API. Find the @Module decorator's imports array and insert at that node's position. Do not search for the string imports: [ and append after it.

Concern String replacement AST-aware edit
Initial effort Minutes An hour or two
Breaks on reformatting or comments Often Rarely
Handles a missing imports array No Yes, by branching on the node
Safe to run twice Hard Easy: check for an existing import first

Nothing reaches the disk until the generator finishes, because every change lands in the virtual Tree first. That is why nx g … --dry-run can list each created and updated file with no risk. Reviewers can preview a new service before anyone commits it.

The tags keep working after generation. The @nx/enforce-module-boundaries lint rule reads them through its depConstraints. For example, type:api may depend only on type:domain and type:util, and scope:payments may not import from scope:billing. A wrong import then fails lint long after scaffolding is done.

import { Tree, readProjectConfiguration, updateProjectConfiguration, updateJson, formatFiles } from '@nx/devkit';
import * as ts from 'typescript';

export async function registerPaymentsApi(tree: Tree, project: string, moduleName: string, importPath: string) {
  // Tags are what @nx/enforce-module-boundaries checks long after generation
  const config = readProjectConfiguration(tree, project);
  config.tags = [...new Set([...(config.tags ?? []), 'scope:payments', 'type:api'])];
  config.targets = { ...config.targets, 'contract-test': { executor: 'nx:run-commands', options: { command: `jest -c ${config.root}/jest.contract.config.ts` } } };
  updateProjectConfiguration(tree, project, config);

  // JSON edits go through parse -> mutate object -> serialize, never text splicing
  updateJson(tree, 'tsconfig.base.json', (json) => {
    json.compilerOptions.paths ??= {};
    json.compilerOptions.paths['@acme/payments-api'] = [`${config.sourceRoot}/index.ts`];
    return json;
  });

  // Parse AppModule and locate nodes instead of searching for 'imports: ['
  const file = `${config.sourceRoot}/app/app.module.ts`;
  const src = tree.read(file, 'utf-8')!;
  const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true);
  const imports = sf.statements.filter(ts.isImportDeclaration);
  // Idempotent: skip if an import of this module already exists
  if (imports.some((i) => (i.moduleSpecifier as ts.StringLiteral).text === importPath)) return;

  let arr: ts.ArrayLiteralExpression | undefined;
  const visit = (n: ts.Node): void => {
    if (ts.isPropertyAssignment(n) && n.name.getText(sf) === 'imports' && ts.isArrayLiteralExpression(n.initializer)) arr = n.initializer;
    ts.forEachChild(n, visit);
  };
  visit(sf);
  if (!arr) throw new Error(`No @Module imports array in ${file}; add a branch to create one`);

  // Apply edits from the end of the file backwards so earlier offsets stay valid
  const at = arr.elements.length ? arr.elements.end : arr.getStart(sf) + 1;
  let out = src.slice(0, at) + (arr.elements.length ? `, ${moduleName}` : moduleName) + src.slice(at);
  const top = imports.length ? imports[imports.length - 1].end : 0;
  out = out.slice(0, top) + `\nimport { ${moduleName} } from '${importPath}';` + out.slice(top);
  tree.write(file, out); // lands in the virtual Tree; --dry-run shows it without touching disk
  await formatFiles(tree);
}

A generator helper that sets scope/type tags and a target via project configuration utilities, updates tsconfig paths with updateJson, and idempotently registers a module in AppModule using TypeScript AST node positions, with all writes staged in the virtual Tree.

Testing generators with the in-memory tree and in CI

Because a generator only ever touches a Tree, you can test it without a real workspace. Use createTreeWithEmptyWorkspace from @nx/devkit/testing to get an in-memory tree and run your generator function against it with a fixed set of options. Then assert on the result directly:

  • tree.exists() for the files your conventions require, such as the health module, the config schema, and the Dockerfile.
  • tree.read(path, 'utf-8') for content that matters, such as the global prefix or the logger wiring.
  • readProjectConfiguration(tree, name) for targets and tags. A missing scope: or type: tag silently disables the boundary rules from the previous section, so treat tags as a first-class assertion.

These tests are fast and catch logic errors in option normalization and file edits. They cannot tell you whether the output compiles. Templates are text, and they rot when Nest, TypeScript, or your shared libraries change underneath them.

Close that gap with a CI job that runs on every change to the plugin and on a schedule. The job generates a throwaway app with nx g, runs nx build and nx test on it, and discards it. The scheduled run matters most, because it shows that an upstream Nest release broke your templates before an engineer finds out mid-feature.

None of this maintains itself. The generator is production code that every new service depends on, so it needs an owner. In most organizations that owner is the platform team, which reviews changes, triages failing scheduled runs, and decides when conventions change.

Evolving the generator, when to avoid it, and the impact on cost and engineering time

That decision has a simple test. If a request changes how the same kind of service is built, such as the choice of message broker, it becomes an option. If it produces a different kind of thing, such as a scheduled worker with no HTTP layer, it becomes a second generator. Keep defaults opinionated. Every option you add is a branch you must test and a choice a new engineer must understand.

A generator only controls day zero. When a convention changes later, ship an Nx migration alongside the generator change so that services generated earlier receive the same edit through the Tree.

Limits and when to skip it

Risk Symptom Mitigation
Too many options Schema reads like a config language Split into separate generators
Too few options Teams copy and fork it Add the option, or document the opinion
Local plugin Other repos can't reuse it Publish it, and accept the release overhead

Skip the generator entirely when you have one or two services, or when nobody will own it. An unowned generator encodes last year's conventions and spreads them.

Illustrative estimate (assumed numbers, not a benchmark)

Assumed inputs: 24 hours to build, 2 hours per month to maintain, 6 hours saved per new service, 10 services in the first year.

  • Net hours = (services × hours saved) − (build hours + 12 × monthly upkeep)
  • Net hours = (10 × 6) − (24 + 24) = 12 hours

With three services in a year, the result is −30 hours. Plug in your own numbers before committing to build one.

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.