| |

Angular 125 🅰️ Builders and Custom CLI Commands

⏱️ Estimated read: 14 min | |

Angular CLI builders are the engines behind every ng command. When you run ng build, ng serve, ng test, or ng lint, the CLI reads the workspace configuration, resolves the target to a builder, and invokes it with the options from angular.json. A builder is a function that receives options and a context and returns an observable of results. The Angular team ships builders for the common tasks, and the same interface is available for custom builders that extend the CLI with project-specific workflows.

The builder API is part of the Angular DevKit, the set of tools that the CLI is built on. A builder package exports a builders.json manifest that maps builder names to their implementation modules. The CLI reads the manifest when the package is installed, and the builder becomes available in angular.json. The @angular-devkit/architect package provides the runtime that invokes builders and manages their lifecycle.

This chapter covers the builder interface, the builders.json manifest, creating a custom builder, integrating it with angular.json, running it via ng run, and the patterns that make builders testable and maintainable.

Key point: A builder is a function that receives a target and context and returns an observable of builder results. Builders are declared in a builders.json manifest and referenced in angular.json by package and builder name. The @angular-devkit/architect package provides the createBuilder function and the runtime. Custom builders are invoked with ng run <project>:<target> and can wrap existing builders or implement entirely new workflows.


Why builders exist

The task-abstraction problem. The CLI needs a uniform way to define, configure, and run tasks. A builder is that uniform interface: a function with a defined input (options) and output (results). The CLI does not need to know what the builder does; it only needs to invoke it with the right options and handle the results.

The configuration problem. Each project in a workspace has targets — build, serve, test, lint — and each target has options. The target maps to a builder, and the options are the builder’s configuration. This indirection allows the same builder to be used with different options in different projects, and allows a project to override the builder for a target.

The extensibility problem. The Angular CLI cannot anticipate every workflow. A team might need to generate a service worker manifest, upload assets to a CDN, or run a code generation step before the build. A custom builder encodes that workflow into the CLI, so it can be invoked the same way as a built-in command.

The composition problem. A custom builder can wrap an existing builder by calling the architect API. This allows a builder to add behavior before or after the standard build, without reimplementing the build itself. The @angular-devkit/architect package provides the scheduleBuilder function for this.

The testability problem. A builder is a function. It can be tested by invoking it with a target and context and asserting on the emitted results. The builder’s logic is separated from the CLI’s argument parsing, which makes it easier to test in isolation.


a. The builder interface

A builder is created with the createBuilder function from @angular-devkit/architect. The function receives a handler that returns an observable.

import { createBuilder } from '@angular-devkit/architect';

export default createBuilder((options, context) => {
  return new Observable((observer) => {
    // do work
    observer.next({ success: true });
    observer.complete();
  });
});

The handler receives two arguments: the options and the context. The options are the builder’s configuration from angular.json, validated against the builder’s schema. The context provides access to the logger, the workspace root, the target, and utilities for scheduling other builders.

The handler returns an observable that emits BuilderOutput objects. Each output has a success boolean and may carry additional data. The observable completes when the builder finishes.

The BuilderOutput interface:

interface BuilderOutput {
  success: boolean;
  [key: string]: unknown;
}

A builder that runs a long process, like a dev server, emits an output when it starts and continues emitting until it is stopped. A builder that runs to completion emits one output and completes.


b. The builders.json manifest

A builder package declares its builders in a builders.json file. The file maps builder names to their implementation modules and schemas.

{
  "$schema": "@angular-devkit/architect/src/builders-schema.json",
  "builders": {
    "custom-build": {
      "implementation": "./src/custom-build/index.js",
      "schema": "./src/custom-build/schema.json",
      "description": "Run a custom build"
    }
  }
}

The package.json of the package declares the builders key that points to the manifest:

{
  "name": "my-builders",
  "version": "1.0.0",
  "builders": "./builders.json"
}

When the package is installed in a workspace, the CLI reads the manifest and makes the builders available. The builder is referenced in angular.json as <package>:<builder>:

{
  "projects": {
    "my-app": {
      "architect": {
        "custom": {
          "builder": "my-builders:custom-build",
          "options": {}
        }
      }
    }
  }
}

The architect section (also called targets in newer configurations) is where targets are defined. Each target has a builder and an options object.


c. The builder schema

Like schematics, builders have a schema.json that defines their options. The schema uses JSON Schema and controls the options’ types, defaults, and descriptions.

{
  "$schema": "http://json-schema.org/schema",
  "title": "Custom Build",
  "type": "object",
  "properties": {
    "outputPath": {
      "type": "string",
      "description": "The output path for the build"
    },
    "watch": {
      "type": "boolean",
      "default": false,
      "description": "Watch for changes"
    }
  },
  "required": ["outputPath"]
}

The schema is validated when the target runs. If the options do not match the schema, the CLI reports an error before the builder is invoked.

The corresponding TypeScript interface types the options in the builder:

interface CustomBuildOptions {
  outputPath: string;
  watch?: boolean;
}

The interface is used by the builder’s handler and by any code that references the builder’s options.


d. Creating a custom builder

A custom builder can do anything: run a script, invoke another builder, transform files, or communicate with a service. The simplest builder runs a shell command.

import { createBuilder } from '@angular-devkit/architect';
import { exec } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);

interface Schema {
  script: string;
}

export default createBuilder<Schema>(async (options, context) => {
  context.logger.info(`Running ${options.script}`);

  try {
    const { stdout, stderr } = await execAsync(options.script);
    if (stdout) context.logger.info(stdout);
    if (stderr) context.logger.warn(stderr);
    return { success: true };
  } catch (err) {
    context.logger.error(String(err));
    return { success: false };
  }
});

The builder logs the command, runs it, and returns a BuilderOutput based on whether it succeeded.

A builder that wraps the standard Angular build uses the scheduleBuilder function from the context:

import { createBuilder } from '@angular-devkit/architect';
import { switchMap } from 'rxjs/operators';

interface Schema {
  buildTarget: string;
}

export default createBuilder<Schema>((options, context) => {
  const target = context.target;
  const buildTarget = context.architect.getTarget(options.buildTarget);

  return context.scheduleTarget(buildTarget).pipe(
    switchMap((result) => {
      // post-build step
      context.logger.info('Build complete');
      return [result];
    })
  );
});

The context.scheduleTarget method runs another target and returns an observable of its results. The custom builder wraps the standard build and adds a post-processing step.


e. Invoking a custom builder

A custom builder is invoked with ng run, which takes a target in the form <project>:<target>:

ng run my-app:custom

The ng run command resolves the target, loads the builder, validates the options, and invokes the builder. The output is printed to the terminal, and the exit code reflects the builder’s success.

Options can be passed on the command line:

ng run my-app:custom --outputPath=dist/custom

The options are merged with the target’s configuration and validated against the schema.

A common use is to define a target that wraps the build:

{
  "architect": {
    "build": {
      "builder": "@angular/build:application",
      "options": { ... }
    },
    "custom-build": {
      "builder": "my-builders:custom-build",
      "options": {
        "buildTarget": "my-app:build"
      }
    }
  }
}

Running ng run my-app:custom-build invokes the custom builder, which schedules the standard build and adds the post-processing step.


f. Testing a builder

A builder is a function, so it can be tested by invoking it with a target and context. The @angular-devkit/architect/testing package provides utilities for this.

import { Architect } from '@angular-devkit/architect';
import { TestingArchitectHost } from '@angular-devkit/architect/testing';
import { schemaRegistry } from '@angular-devkit/core';

describe('custom builder', () => {
  let architect: Architect;
  let architectHost: TestingArchitectHost;

  beforeEach(async () => {
    const registry = new schemaRegistry.SchemaRegistry();
    architectHost = new TestingArchitectHost();
    architect = new Architect(architectHost, registry);

    architectHost.addBuilder(
      'my-builders:custom-build',
      require('./index').default
    );
  });

  it('runs successfully', async () => {
    const run = await architect.scheduleBuilder('my-builders:custom-build', {
      script: 'echo hello',
    });

    const result = await run.result;
    expect(result.success).toBe(true);
    await run.stop();
  });
});

The TestingArchitectHost provides an in-memory architect that runs builders without the CLI. The test schedules the builder with options, awaits the result, and asserts on the success flag.

The scheduleBuilder method returns a BuilderRun with a result promise and a stop method. The test must call stop to clean up, even if the builder completes on its own.


Complete Example Session

# ============================================
# PART 1: CREATE THE BUILDER PACKAGE
# ============================================
mkdir my-builders && cd my-builders
npm init -y
npm install @angular-devkit/architect rxjs
// ============================================
// PART 2: BUILDERS.JSON MANIFEST
// ============================================
{
  "$schema": "@angular-devkit/architect/src/builders-schema.json",
  "builders": {
    "post-build": {
      "implementation": "./src/post-build/index.js",
      "schema": "./src/post-build/schema.json",
      "description": "Run a post-build step"
    }
  }
}
// ============================================
// PART 3: PACKAGE.JSON
// ============================================
{
  "name": "my-builders",
  "version": "1.0.0",
  "builders": "./builders.json",
  "scripts": {
    "build": "tsc -p tsconfig.json"
  }
}
// ============================================
// PART 4: SCHEMA.JSON
// ============================================
{
  "$schema": "http://json-schema.org/schema",
  "title": "Post Build",
  "type": "object",
  "properties": {
    "buildTarget": {
      "type": "string",
      "description": "The build target to wrap"
    },
    "message": {
      "type": "string",
      "default": "Build complete",
      "description": "Message to log after the build"
    }
  },
  "required": ["buildTarget"]
}
// ============================================
// PART 5: BUILDER IMPLEMENTATION
// ============================================
import { createBuilder } from '@angular-devkit/architect';
import { switchMap } from 'rxjs/operators';

interface Schema {
  buildTarget: string;
  message?: string;
}

export default createBuilder<Schema>((options, context) => {
  const buildTarget = context.architect.getTarget(options.buildTarget);

  return context.scheduleTarget(buildTarget).pipe(
    switchMap((result) => {
      context.logger.info(options.message ?? 'Build complete');
      return [result];
    })
  );
});
// ============================================
// PART 6: CONFIGURE ANGULAR.JSON
// ============================================
{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "builder": "@angular/build:application",
          "options": {}
        },
        "post-build": {
          "builder": "my-builders:post-build",
          "options": {
            "buildTarget": "my-app:build",
            "message": "Build finished"
          }
        }
      }
    }
  }
}
# ============================================
# PART 7: BUILD AND LINK
# ============================================
npm run build
npm link
cd ../my-app
npm link my-builders
# ============================================
# PART 8: RUN THE CUSTOM BUILDER
# ============================================
ng run my-app:post-build
# Output:
# Build finished
// ============================================
// PART 9: TEST THE BUILDER
// ============================================
import { Architect } from '@angular-devkit/architect';
import { TestingArchitectHost } from '@angular-devkit/architect/testing';
import { schemaRegistry } from '@angular-devkit/core';

describe('post-build builder', () => {
  let architect: Architect;
  let architectHost: TestingArchitectHost;

  beforeEach(async () => {
    const registry = new schemaRegistry.SchemaRegistry();
    architectHost = new TestingArchitectHost();
    architect = new Architect(architectHost, registry);
  });

  it('runs successfully', async () => {
    const run = await architect.scheduleBuilder('my-builders:post-build', {
      buildTarget: 'my-app:build',
    });
    const result = await run.result;
    expect(result.success).toBe(true);
    await run.stop();
  });
});
# ============================================
# PART 10: PUBLISH
# ============================================
npm publish

These ten parts cover creating the package, the builders.json manifest, the package.json, the schema, the implementation, configuring angular.json, building and linking, running the builder, testing it, and publishing.


Quick Reference

Builder Concepts

ConceptDefinition
BuilderFunction that receives options and context, returns observable
TargetNamed configuration in angular.json
ArchitectRuntime that invokes builders
BuilderOutputResult object with success boolean
builders.jsonManifest of builders in a package

Builder API

APIPurpose
createBuilder(handler)Create a builder
context.loggerLog info, warn, error
context.scheduleTarget(target)Run another target
context.architect.getTarget(name)Resolve a target
context.targetThe current target

Schema Options

OptionPurpose
typeData type
defaultDefault value
descriptionHelp text
requiredMark as required

Invocation

CommandPurpose
ng run <project>:<target>Run a target
ng run <project>:<target> --option=valuePass options
ng buildShortcut for the build target
ng serveShortcut for the serve target

Testing

APIPurpose
TestingArchitectHostIn-memory architect host
ArchitectRuntime for builders
scheduleBuilder(name, options)Run a builder in a test
run.resultPromise of the result
run.stop()Stop the builder

Packaging

FilePurpose
builders.jsonManifest of builders
schema.jsonOptions for one builder
package.jsonbuilders key points to manifest

Best Practices

✅ Do This:

// Use createBuilder
export default createBuilder<Schema>((options, context) => { ... });

// Return BuilderOutput
return { success: true };

// Use context.logger
context.logger.info('Building...');

// Wrap existing builders with scheduleTarget
context.scheduleTarget(buildTarget).pipe(switchMap(...));

// Test with TestingArchitectHost
const run = await architect.scheduleBuilder('my-builders:post-build', options);

❌ Don’t Do This:

// Throw instead of returning failure
throw new Error('failed');  // ❌ return { success: false } instead

// Use console.log
console.log('building');  // ❌ use context.logger

// Forget to complete the observable
return new Observable(() => {});  // ❌ never completes

// Reimplement the build
// Call the standard builder via scheduleTarget  // ❌ don't duplicate

Common Pitfalls

PitfallWhy It HappensFix
Builder not foundbuilders.json not in package.jsonAdd builders key
Options not validatedSchema missingAdd schema.json
Observable never completesMissing observer.complete()Complete the observable
Test hangsrun.stop() not calledCall stop in afterEach
Schedule target failsWrong target nameUse <project>:<target> format
Build output not propagatedNot returned from switchMapReturn the result

Real-World Examples

1. Simple Builder

export default createBuilder((options, context) => {
  context.logger.info('Hello from builder');
  return { success: true };
});

2. Shell Command Builder

const { stdout } = await execAsync(options.script);
context.logger.info(stdout);
return { success: true };

3. Wrapping the Build

context.scheduleTarget(buildTarget).pipe(
  switchMap((result) => {
    // post-processing
    return [result];
  })
);

4. Builders.json

{
  "builders": {
    "post-build": {
      "implementation": "./src/index.js",
      "schema": "./src/schema.json"
    }
  }
}

5. Angular.json Target

{
  "post-build": {
    "builder": "my-builders:post-build",
    "options": { "buildTarget": "my-app:build" }
  }
}

6. Run the Builder

ng run my-app:post-build

7. Test with Architect

const run = await architect.scheduleBuilder('my-builders:post-build', options);
const result = await run.result;

8. Clean Up

afterEach(async () => {
  await run.stop();
});

9. Publish

{
  "name": "my-builders",
  "builders": "./builders.json"
}

10. Link Locally

npm link
cd ../my-app && npm link my-builders

Visual

Builder Flow

┌──────────────────────────────────────────────────────────────┐
│  ng run my-app:post-build                                    │
│       │                                                      │
│       ▼                                                      │
│  CLI reads angular.json → target → builder                   │
│       │                                                      │
│       ▼                                                      │
│  Architect loads builder from builders.json                  │
│       │                                                      │
│       ▼                                                      │
│  Validates options against schema.json                       │
│       │                                                      │
│       ▼                                                      │
│  Invokes builder handler with options and context            │
│       │                                                      │
│       ▼                                                      │
│  Builder returns Observable<BuilderOutput>                   │
│       │                                                      │
│       ▼                                                      │
│  CLI prints output and exits with code                       │
└──────────────────────────────────────────────────────────────┘

Wrapping an Existing Builder

┌──────────────────────────────────────────────────────────────┐
│  Custom Builder                                              │
│       │                                                      │
│       ├── context.scheduleTarget('my-app:build')             │
│       │   └── Runs the standard Angular build                │
│       │                                                      │
│       ├── switchMap((result) => {                            │
│       │     // post-build step                               │
│       │     return [result];                                 │
│       │   })                                                 │
│       │                                                      │
│       └── Returns the combined result                        │
└──────────────────────────────────────────────────────────────┘

Builder Package Structure

┌──────────────────────────────────────────────────────────────┐
│  my-builders/                                                │
│  ├── package.json          (builders: "./builders.json")     │
│  ├── builders.json         (manifest of builders)            │
│  └── src/                                                    │
│      └── post-build/                                         │
│          ├── index.js      (implementation)                  │
│          └── schema.json   (options)                         │
└──────────────────────────────────────────────────────────────┘

Testing

┌──────────────────────────────────────────────────────────────┐
│  TestingArchitectHost                                        │
│       │                                                      │
│       ▼                                                      │
│  Architect                                                   │
│       │                                                      │
│       ▼                                                      │
│  architect.scheduleBuilder('my-builders:post-build', opts)   │
│       │                                                      │
│       ▼                                                      │
│  BuilderRun                                                  │
│  ├── result: Promise<BuilderOutput>                          │
│  └── stop(): Promise<void>                                   │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
BuilderFunction that transforms options into a result
createBuilderCreates a builder
builders.jsonManifest of builders in a package
schema.jsonOptions and validation
angular.jsonReferences builders by package and name
ng runInvokes a custom builder
context.scheduleTargetRuns another target
BuilderOutputResult with success boolean
TestingArchitectHostIn-memory architect for tests
package.jsonbuilders key points to manifest

Key takeaways:

  • Builders are the engines behind every ng command. The CLI resolves a target to a builder and invokes it with the target’s options. Understanding builders means understanding how the CLI runs tasks.
  • A builder is a function that returns an observable of BuilderOutput. The handler receives options and context and emits results. The observable completes when the builder finishes.
  • A builder package declares its builders in builders.json. The manifest maps builder names to implementation modules and schemas, and the package’s package.json points to it.
  • The schema validates the options. The schema.json defines types, defaults, and required fields. The CLI validates the target’s options against the schema before invoking the builder.
  • Custom builders can wrap existing builders. context.scheduleTarget runs another target and returns its results, so a builder can add behavior before or after the standard build.
  • Custom builders are invoked with ng run. The command takes a target in the form <project>:<target> and passes options on the command line.
  • Builders are testable in isolation. The TestingArchitectHost provides an in-memory architect that runs builders without the CLI, and scheduleBuilder returns a BuilderRun with a result promise and a stop method.

Remember: Angular CLI builders are the extension point for the CLI. A builder is a function that receives options and context and returns an observable of results. It is declared in a manifest, configured in angular.json, validated by a schema, and invoked with ng run. Custom builders can wrap existing builders, run shell commands, transform files, or implement entirely new workflows. The same architect runtime that powers ng build and ng serve powers custom builders, so they behave consistently with the built-in commands. Whether you are adding a post-build step, generating a service worker manifest, or running a code generation task, the pattern is the same: define the schema, write the handler, declare the builder, configure the target, and test it with the TestingArchitectHost.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!