14 Apr 2026
8 min

Angular Schematics Deep Dive — Part 3: Building Installation Schematics with ng add

This is Part 3 of a 6-part series on Angular Schematics.


🤖 A note on this article: I used Claude to help reformat and structure the content to make it clearer and more presentable for publication.


References


In Part 2 we built a custom ng generate schematic that scaffolds a component with your organisation’s selector prefix. We understand the workspace setup, collection.json, schema.json, factory functions, and file templates.

Part 3 is about a different problem entirely. Not generating code inside a project that already has your library – but automating what happens the very first time a developer installs it.

When a developer runs:

ng add @acme/ui

They should not have to read a 12-step setup guide. The library should configure itself – install its peer dependencies, update angular.json, register its module, add any required styles, and leave the workspace in a working state. That is what an ng add schematic does.

By the end of this article you will have built a complete ng add schematic that handles the full installation lifecycle of a library.


Table of Contents

  1. What Happens When You Run ng add
  2. How ng add Differs from ng generate
  3. Registering the ng-add Entry Point
  4. The Installation Schematic – Full Walkthrough
  5. Step 1 – Adding Dependencies to package.json
  6. Step 2 – Scheduling npm install
  7. Step 3 – Updating angular.json
  8. Step 4 – Adding Global Styles
  9. Putting It All Together
  10. Running and Testing Locally
  11. Why This Matters – The Developer Experience Argument
  12. ng add vs ng generate vs ng update – The Complete Picture
  13. Summary & What’s Next

What Happens When You Run ng add

ng add is a first-class Angular CLI command designed for one specific job: installing and configuring a library from scratch. When you run it, the CLI executes the following sequence:

ng add @acme/ui


1. npm install @acme/ui (if not already installed)


2. Read package.json of @acme/ui
   → find "schematics": "./schematics/collection.json"


3. Read collection.json
   → find the "ng-add" schematic entry


4. Execute the ng-add factory function
Tree mutations staged
Tasks scheduled (e.g. NodePackageInstallTask)


5. Commit staged Tree to disk
6. Run scheduled tasks (npm install for peer deps)

The key distinction from ng generate is step 1 – the CLI installs the package itself before running the schematic. This means by the time your factory function runs, the package is already in node_modules and its own package.json is available. You have access to version information, peer dependency declarations, and any assets the package ships.


How ng add Differs from ng generate

Both use the same underlying DevKit primitives – Tree, Rule, chain, applyTemplates. The differences are in purpose, trigger, and what the CLI does around the schematic:

ng generateng add
PurposeScaffold code in an existing projectInstall and configure a library for the first time
TriggerManual, on demand, repeatableOnce per library installation
Package installNo – package must already be installedYes – CLI installs the package first
Entry in collection.jsonNamed schematic e.g. "component"Reserved name "ng-add"
Idempotency expectationUsually idempotentShould guard against running twice
Typical operationsCreate files, modify sourceUpdate package.json, angular.json, AppModule, styles

The reserved name "ng-add" is important – the CLI looks for exactly this key in collection.json. Any other name will not be found by ng add.


Registering the ng-add Entry Point

In your library’s collection.json, add the ng-add entry alongside any existing schematics:

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "ng-add": {
      "description": "Installs and configures @acme/ui in the workspace",
      "factory": "./ng-add/index#ngAdd",
      "schema": "./ng-add/schema.json"
    }
  }
}

And in your library’s package.json, the „schematics” field must point to this collection:

{
  "name": "@acme/ui",
  "version": "1.0.0",
  "schematics": "./schematics/collection.json"
}

The project structure for the ng-add schematic sits inside your library’s schematics folder:

@acme/ui/
├── package.json
└── schematics/
    ├── collection.json
    └── ng-add/
        ├── index.ts        ← factory function
        ├── schema.jsonoptions
        └── schema.tsTypeScript interface

The Installation Schematic – Full Walkthrough

Let’s define what our ng add schematic needs to do when a developer installs @acme/ui:

  1. Add any required peer dependencies to package.json
  2. Trigger npm install to install them
  3. Add the library’s stylesheet path to angular.json
  4. Add the library’s base CSS import to the global styles.scss

Each of these is a separate Rule. We’ll build each one, then compose them.

First, define the schema. Create schematics/ng-add/schema.json:


  "$schema": "http://json-schema.org/schema",
  "$id": "NgAddSchema",
  "title": "ng-add schematic for @acme/ui",
  "type": "object",
  "properties": {
    "project": {
      "type": "string",
      "description": "The name of the Angular project to configure",
      "$default": { "$source": "projectName" }
    },
    "skipStyles": {
      "type": "boolean",
      "description": "Skip adding the global stylesheet",
      "default": false
    }
  }
}

And schematics/ng-add/schema.ts:

export interface NgAddSchema {
  project: string;
  skipStyles: boolean;
}

Step 1 – Adding Dependencies to package.json

The first rule adds any peer dependencies the library requires to the consumer’s package.json. We read the file, add the entries to the dependencies object, and write it back.

import { Rule, Tree, SchematicsException } from '@angular-devkit/schematics';

function addDependencies(): Rule {
  return (tree: Tree) => {
    const pkgPath = 'package.json';
    const buffer = tree.read(pkgPath);

    if (!buffer) {
      throw new SchematicsException('Could not find package.json in workspace root.');
    }

    const pkg = JSON.parse(buffer.toString('utf-8'));

    // Add peer dependencies your library requires
    pkg.dependencies = pkg.dependencies || {};
    pkg.dependencies['@acme/ui'] = '^1.0.0';
    pkg.dependencies['@acme/ui-icons'] = '^1.0.0'; // example peer dep

    tree.overwrite(pkgPath, JSON.stringify(pkg, null, 2));
    return tree;
  };
}

💡 Idempotency: Always check whether the dependency already exists before writing. If you are running the schematic again on a workspace that already has the package, you should not downgrade or overwrite a version the developer has already pinned to.

A more robust version guards against overwriting existing entries:

if (!pkg.dependencies['@acme/ui-icons']) {
  pkg.dependencies['@acme/ui-icons'] = '^1.0.0';
}

Step 2 – Scheduling npm install

Writing entries to package.json does not install them. For that, you schedule a NodePackageInstallTask via the SchematicContext. Tasks run after all Rules have executed and the Tree has been committed to disk – so by the time npm install fires, your updated package.json is already on disk.

import {
  Rule, Tree, SchematicContext
} from '@angular-devkit/schematics';
import {
  NodePackageInstallTask
} from '@angular-devkit/schematics/tasks';

function installDependencies(): Rule {
  return (_tree: Tree, context: SchematicContext) => {
    context.addTask(new NodePackageInstallTask());
    context.logger.info('Scheduling npm install...');
  };
}

The NodePackageInstallTask runs npm install (or yarn install / pnpm install – the CLI detects the package manager in use). You do not need to specify what to install – it simply runs the package manager against the current package.json, picking up whatever your previous Rule wrote.


Step 3 – Updating angular.json

Many libraries ship assets – fonts, icon sets, pre-built CSS – that need to be registered in the styles or assets arrays of angular.json. Reading and writing angular.json follows the same JSON parse-mutate-reserialise pattern, but requires navigating to the right project node first.

import { Rule, Tree, SchematicsException } from '@angular-devkit/schematics';
import { NgAddSchema } from './schema';

function addStylesToAngularJson(options: NgAddSchema): Rule {
  return (tree: Tree) => {
    const angularJsonPath = 'angular.json';
    const buffer = tree.read(angularJsonPath);

    if (!buffer) {
      throw new SchematicsException('Could not find angular.json.');
    }

    const angularJson = JSON.parse(buffer.toString('utf-8'));

    // Resolve the target project - falls back to defaultProject
    const projectName =
      options.project ||
      angularJson.defaultProject;

    const project = angularJson.projects[projectName];

    if (!project) {
      throw new SchematicsException(
        `Project "${projectName}" not found in angular.json.`
      );
    }

    const buildOptions = project.architect?.build?.options;

    if (!buildOptions) {
      throw new SchematicsException(
        `Could not find build options for project "${projectName}".`
      );
    }

    // Add the library's pre-built stylesheet
    const styleEntry = 'node_modules/@acme/ui/styles/acme-ui.css';

    buildOptions.styles = buildOptions.styles || [];

    if (!buildOptions.styles.includes(styleEntry)) {
      buildOptions.styles.unshift(styleEntry); // prepend so it loads before app styles
      tree.overwrite(angularJsonPath, JSON.stringify(angularJson, null, 2));
    }

    return tree;
  };
}

The unshift rather than push is intentional – library base styles should load before application styles so that application styles can safely override them.


Step 4 – Adding Global Styles

Some libraries require a base import in the application’s global stylesheet – a CSS custom property definition, a font face declaration, or a reset. Rather than using angular.json’s styles array for this, some setups prefer an explicit @import in styles.scss.

import { Rule, Tree } from '@angular-devkit/schematics';
import { NgAddSchema } from './schema';

function addGlobalStyleImport(options: NgAddSchema): Rule {
  return (tree: Tree) => {
    if (options.skipStyles) return tree;

    // Support both .scss and .css global stylesheets
    const stylePaths = [
      'src/styles.scss',
      'src/styles.css',
      'src/styles.sass',
    ];

    const stylePath = stylePaths.find(p => tree.exists(p));

    if (!stylePath) return tree; // no global stylesheet found - skip silently

    const content = tree.read(stylePath)!.toString('utf-8');
    const importLine = `@import '@acme/ui/styles/tokens';\n`;

    if (content.includes(importLine)) return tree; // idempotent guard

    tree.overwrite(stylePath, importLine + content); // prepend
    return tree;
  };
}

The skipStyles option from schema.json gives consumers an escape hatch – useful when a workspace has a custom styling pipeline that handles this differently.


Putting It All Together

The factory function composes all four Rules into a single chain:

import {
  Rule, SchematicContext, Tree, chain
} from '@angular-devkit/schematics';
import { NgAddSchema } from './schema';

export function ngAdd(options: NgAddSchema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.logger.info('Setting up @acme/ui...');

    return chain([
      addDependencies(),
      installDependencies(),
      addStylesToAngularJson(options),
      addGlobalStyleImport(options),
    ])(tree, context);
  };
}

The order matters here. addDependencies() must run before installDependencies() so that package.json is updated before npm install reads it. Everything else can run in any order, but grouping file operations before the install task is clean practice.

When a developer runs ng add @acme/ui, the terminal output looks like this:

✔ Package successfully installed.
UPDATE package.json (1842 bytes)
UPDATE angular.json (4103 bytes)
UPDATE src/styles.scss (112 bytes)
✔ Packages installed successfully.

Every file that was touched is listed. The developer sees exactly what changed before they commit.


Running and Testing Locally

Because ng add installs the package first, local testing requires a slightly different approach than npm link:

# Pack the library as a tarball
cd your-library
npm run build
npm pack
# Produces: acme-ui-1.0.0.tgz

# In your test Angular workspace
ng add ./path/to/acme-ui-1.0.0.tgz

# Or install from a local path and run ng add separately
npm install ../your-library
ng add @acme/ui

# Preview without committing
ng add @acme/ui --dry-run

The --dry-run flag is particularly valuable for ng add schematics because the changes are spread across multiple files. Reviewing the full diff before committing gives the developer confidence in what the schematic is doing to their workspace.


Why This Matters – The Developer Experience Argument

A library without an ng add schematic asks its consumers to do three things: read documentation, understand the configuration, and execute a series of manual steps correctly. Each of those steps is an opportunity for error, and errors in setup produce confusing runtime failures that are hard to diagnose.

A library with a well-written ng add schematic asks its consumers to do one thing: run one command. The rest is guaranteed.

This is not a minor convenience – it is the difference between a library that gets adopted and one that doesn’t. Developers evaluate libraries partly on how painful the setup is. If the first experience of your library is a frictionless ng add that leaves the workspace correctly configured and immediately working, that is a strong signal that the library is well-maintained and the team behind it cares about developer experience.

The Angular ecosystem has established ng add as the expected installation path for any serious library. Angular Material, NgRx, Angular CDK, Transloco, TailwindCSS, PrimeNG – all of them ship ng add schematics. Consumer developers now expect it. If your library doesn’t have one, it stands out – for the wrong reason.


Summary & What’s Next

We built a complete ng add schematic that handles the full installation lifecycle of a library: adding dependencies to package.json, scheduling npm install, updating angular.json with the library’s stylesheet, and adding a global style import – all from a single ng add command.

The key concepts to take forward:

  • The reserved "ng-add" key in collection.json is what makes ng add work – no other name will be picked up by the CLI.
  • The CLI installs the package before running the schematic – your factory function runs with the package already in node_modules.
  • NodePackageInstallTask runs after all Rules commit – always schedule it last so it picks up your updated package.json.
  • Every file operation should be guarded with an existence check and an idempotency check – ng add should be safe to run on a workspace that was already configured.
  • The --dry-run flag is your best friend during development – always test with it first.

In Part 4 we turn to the third and final schematic type: ng update migrations. We’ll cover migrations.json, version-gating, and using the TypeScript Compiler API to safely rewrite existing source code – the mechanism that makes breaking library changes manageable at scale.

Series Roadmap

PartTopicStatus
Part 1Understanding Angular Schematics – Architecture & Core Concepts✅ Published
Part 2Creating Custom Generators with ng generate✅ Published
Part 3Building Installation Schematics with ng addYou are here
Part 4Writing Migration Schematics with ng update🔜 Coming Soon
Part 5Testing Schematics with Angular DevKit🔜 Coming Soon
Part 6Advanced Patterns, Publishing & Nx Integration🔜 Coming Soon

Built with Angular v21 · @angular-devkit/schematics · @angular-devkit/schematics/tasks · @angular-devkit/core

Share this post

Sign up for our newsletter

Stay up-to-date with the trends and be a part of a thriving community.