Angular 125 🅰️ Builders and Custom CLI Commands
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
| Concept | Definition |
|---|---|
| Builder | Function that receives options and context, returns observable |
| Target | Named configuration in angular.json |
| Architect | Runtime that invokes builders |
BuilderOutput | Result object with success boolean |
builders.json | Manifest of builders in a package |
Builder API
| API | Purpose |
|---|---|
createBuilder(handler) | Create a builder |
context.logger | Log info, warn, error |
context.scheduleTarget(target) | Run another target |
context.architect.getTarget(name) | Resolve a target |
context.target | The current target |
Schema Options
| Option | Purpose |
|---|---|
type | Data type |
default | Default value |
description | Help text |
required | Mark as required |
Invocation
| Command | Purpose |
|---|---|
ng run <project>:<target> | Run a target |
ng run <project>:<target> --option=value | Pass options |
ng build | Shortcut for the build target |
ng serve | Shortcut for the serve target |
Testing
| API | Purpose |
|---|---|
TestingArchitectHost | In-memory architect host |
Architect | Runtime for builders |
scheduleBuilder(name, options) | Run a builder in a test |
run.result | Promise of the result |
run.stop() | Stop the builder |
Packaging
| File | Purpose |
|---|---|
builders.json | Manifest of builders |
schema.json | Options for one builder |
package.json | builders 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Builder not found | builders.json not in package.json | Add builders key |
| Options not validated | Schema missing | Add schema.json |
| Observable never completes | Missing observer.complete() | Complete the observable |
| Test hangs | run.stop() not called | Call stop in afterEach |
| Schedule target fails | Wrong target name | Use <project>:<target> format |
| Build output not propagated | Not returned from switchMap | Return 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
| Item | Value |
|---|---|
| Builder | Function that transforms options into a result |
createBuilder | Creates a builder |
builders.json | Manifest of builders in a package |
schema.json | Options and validation |
angular.json | References builders by package and name |
ng run | Invokes a custom builder |
context.scheduleTarget | Runs another target |
BuilderOutput | Result with success boolean |
TestingArchitectHost | In-memory architect for tests |
package.json | builders key points to manifest |
Key takeaways:
- Builders are the engines behind every
ngcommand. 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’spackage.jsonpoints to it. - The schema validates the options. The
schema.jsondefines 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.scheduleTargetruns 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
TestingArchitectHostprovides an in-memory architect that runs builders without the CLI, andscheduleBuilderreturns aBuilderRunwith 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.
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!