Mert Özen Further With Every Line
DevOps Versioning and Release Management — #06

Publishing an Angular Library to npm: ng-packagr, peerDependencies, dist-tags and Trusted Publishing

Mert Özen Nov 14, 2026 13 min 1 views
Publishing an Angular Library to npm: ng-packagr, peerDependencies, dist-tags and Trusted Publishing

Package an Angular library in the Angular Package Format with ng-packagr, publish the dist output rather than the source folder, and declare the Angular range you support with peerDependencies. For @bookstore/ui we set up a secondary entry point and the exports field, show ERESOLVE when the peer range is not met and npm 11 refusing a prerelease without a tag, and cover dist-tags, deprecate and a trusted publishing workflow.

Version note: The examples in this post were run with Angular 22.2 (ng-packagr 22.2.6, TypeScript 6.0), Node.js 24, npm 11.19 and actionlint 1.7.12. npm publish was only run with --dry-run; no package was published to any registry. The trusted publishing workflow was validated with actionlint; it was not run on GitHub.

Guess First

We moved the price display in bookstore-web, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, into a library called @bookstore/ui so that other applications such as the institutional portal can use it too. Before publishing, Ayşe wants to see what goes into the package and runs the command in the library's source folder:

$ npm pack projects/bookstore/ui --dry-run

What ends up in the package? Compiled JavaScript or TypeScript sources? Can an application that installs this package use it?

npm notice 1.4kB README.md
npm notice 131B money/ng-package.json
npm notice 427B money/src/money.ts
npm notice 25B money/src/public-api.ts
npm notice 168B ng-package.json
npm notice 495B package.json
npm notice 652B src/lib/price-tag.spec.ts
npm notice 500B src/lib/price-tag.ts
npm notice 80B src/public-api.ts
npm notice 476B tsconfig.lib.json
npm notice 401B tsconfig.lib.prod.json
npm notice 431B tsconfig.spec.json
npm notice total files: 12

There is not a single JavaScript file in the package: TypeScript sources, a test file and tsconfigs. An application installing it cannot find the compiled format Angular expects; the Angular documentation also says applications must be built against the library's build output, not its .ts source. The right folder is dist/bookstore/ui. In this post we create the library, package it with ng-packagr, declare which Angular versions it supports with peerDependencies, and cover the mechanics of publishing with dist-tags, deprecate and trusted publishing. We covered the security side of the npm supply chain (verifying provenance, lifecycle scripts) in the "Hardening the npm Supply Chain" post of the Frontend Security series; here we focus on the mechanics on the publishing side.

Creating the Library: ng generate library and a Secondary Entry Point

The library lives in the same Angular workspace as the application:

$ npx ng generate library @bookstore/ui
CREATE projects/bookstore/ui/ng-package.json (167 bytes)
CREATE projects/bookstore/ui/package.json (217 bytes)
CREATE projects/bookstore/ui/src/public-api.ts (62 bytes)
...
UPDATE angular.json (2835 bytes)
UPDATE package.json (860 bytes)
UPDATE tsconfig.json (1136 bytes)

The schematic creates a library project under projects/bookstore/ui, adds it to angular.json and puts ng-packagr into devDependencies. The library has two parts. Money formatting is useful on its own, so we put it into a separate secondary entry point, @bookstore/ui/money. A secondary entry point is a subfolder with its own ng-package.json; ng-packagr discovers it automatically:

{
  "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json",
  "lib": {
    "entryFile": "src/public-api.ts"
  }
}
import { Pipe, PipeTransform } from '@angular/core';

export interface Money {
  amount: number;
  currency: string;
}

@Pipe({ name: 'money' })
export class MoneyPipe implements PipeTransform {
  transform(value: Money | null | undefined, locale = 'en-US'): string {
    if (!value) {
      return '';
    }
    return new Intl.NumberFormat(locale, { style: 'currency', currency: value.currency }).format(value.amount);
  }
}

The component in the main entry point imports the pipe through the package path. The Angular documentation also recommends using the package path rather than relative paths between entry points of the same library; relative imports break the boundaries between entry points:

import { Component, input } from '@angular/core';
import { Money, MoneyPipe } from '@bookstore/ui/money';

@Component({
  selector: 'bs-price-tag',
  imports: [MoneyPipe],
  template: `
    <span class="price">{{ price() | money: locale() }}</span>
    @if (listPrice(); as old) {
      <s class="list-price">{{ old | money: locale() }}</s>
    }
  `,
})
export class PriceTag {
  readonly price = input.required<Money>();
  readonly listPrice = input<Money>();
  readonly locale = input('en-US');
}
/*
 * Public API Surface of @bookstore/ui
 */

export * from './lib/price-tag';

public-api.ts lists everything the package exposes. A class that is not here does not exist for the package's users; this defines the library's "public API" in SemVer terms. So that the application in the workspace can find the library, the schematic adds a path to tsconfig.json; we added the second line for the secondary entry point, otherwise @bookstore/ui/money does not resolve:

"paths": {
  "@bookstore/ui": ["./dist/bookstore/ui"],
  "@bookstore/ui/*": ["./dist/bookstore/ui/*"]
}

ng-packagr Output and the Angular Package Format

Let's build the library:

$ npx ng build @bookstore/ui
Building entry point '@bookstore/ui/money'
✔ Compiling with Angular sources in partial compilation mode.
✔ Generating FESM and DTS bundles
✔ Built @bookstore/ui/money
Building entry point '@bookstore/ui'
✔ Compiling with Angular sources in partial compilation mode.
✔ Generating FESM and DTS bundles
✔ Writing package manifest
✔ Built @bookstore/ui
$ find dist/bookstore/ui -type f | sort
dist/bookstore/ui/.npmignore
dist/bookstore/ui/fesm2022/bookstore-ui-money.mjs
dist/bookstore/ui/fesm2022/bookstore-ui-money.mjs.map
dist/bookstore/ui/fesm2022/bookstore-ui.mjs
dist/bookstore/ui/fesm2022/bookstore-ui.mjs.map
dist/bookstore/ui/money/package.json
dist/bookstore/ui/package.json
dist/bookstore/ui/README.md
dist/bookstore/ui/types/bookstore-ui-money.d.ts
dist/bookstore/ui/types/bookstore-ui.d.ts

ng-packagr produces a package in the Angular Package Format (APF): a single-file ES module bundle per entry point (fesm2022), TypeScript type definitions (types) and a package.json that ties them together. Compilation runs in partial compilation mode. According to the Angular documentation this is the recommended mode for libraries published to npm, because the output stays stable across Angular patch versions; full mode depends on Angular internals and fits only cases where the library and the application are always built together. A summary of the generated package.json:

{
  "name": "@bookstore/ui",
  "version": "1.0.0",
  "peerDependencies": {
    "@angular/common": "^22.2.0",
    "@angular/core": "^22.2.0"
  },
  "dependencies": {
    "tslib": "^2.3.0"
  },
  "sideEffects": false,
  "module": "fesm2022/bookstore-ui.mjs",
  "typings": "types/bookstore-ui.d.ts",
  "exports": {
    "./package.json": {
      "default": "./package.json"
    },
    ".": {
      "types": "./types/bookstore-ui.d.ts",
      "default": "./fesm2022/bookstore-ui.mjs"
    },
    "./money": {
      "types": "./types/bookstore-ui-money.d.ts",
      "default": "./fesm2022/bookstore-ui-money.mjs"
    }
  },
  "type": "module"
}

The exports field decides through which paths the package can be imported: the root (@bookstore/ui) and ./money. It works like a gate; an application trying to import an internal path that is not listed, such as @bookstore/ui/fesm2022/bookstore-ui.mjs, gets an error. That takes the package's internal file layout out of the public API, so reorganizing files in the next release is not a breaking change. money/package.json is only needed during development; the .npmignore ng-packagr generates keeps it out of the package.

The library's tests run with Vitest, like the application's. Because the tsconfig paths point to the dist folder, a build is needed first:

import { TestBed } from '@angular/core/testing';
import { PriceTag } from './price-tag';

describe('PriceTag', () => {
  it('formats the price with its currency', async () => {
    const fixture = TestBed.createComponent(PriceTag);
    fixture.componentRef.setInput('price', { amount: 420, currency: 'TRY' });
    fixture.componentRef.setInput('listPrice', { amount: 480, currency: 'TRY' });
    await fixture.whenStable();
    const host = fixture.nativeElement as HTMLElement;
    expect(host.querySelector('.price')?.textContent).toBe('TRY\u00a0420.00');
    expect(host.querySelector('.list-price')?.textContent).toBe('TRY\u00a0480.00');
  });
});
$ npx ng build @bookstore/ui
$ npx ng test @bookstore/ui --watch=false
 Test Files  1 passed (1)
      Tests  1 passed (1)

The   in the expected value may stand out: Intl.NumberFormat puts a no-break space instead of a normal space between the currency code and the amount. When we first wrote the test expecting 'TRY 420.00', we got a failure saying two strings that look identical on screen are not equal.

peerDependencies: Declaring the Supported Angular Range

The package.json in the source folder holds the package's version and dependencies:

{
  "name": "@bookstore/ui",
  "version": "1.0.0",
  "description": "Shared Angular UI components of Bookstore.",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/example/bookstore.git",
    "directory": "web/projects/bookstore/ui"
  },
  "peerDependencies": {
    "@angular/common": "^22.2.0",
    "@angular/core": "^22.2.0"
  },
  "dependencies": {
    "tslib": "^2.3.0"
  },
  "sideEffects": false,
  "publishConfig": {
    "access": "public"
  }
}

Angular packages are in peerDependencies, not dependencies. The difference matters: a package listed in dependencies is installed with the library, and if its version differs from the application's, a second copy appears in node_modules. In Angular, two copies of @angular/core mean two separate injector worlds and runtime errors that are hard to understand. peerDependencies says "you bring this package, I work with this range". The Angular documentation also recommends listing @angular/* packages as peerDependencies in libraries; helpers such as tslib that do not need to be shared with the application can stay in dependencies.

The range is what the library promises. ^22.2.0 accepts 22.2 and later, but not 23. We tried installing the package into an application still on Angular 21:

$ npm pack dist/bookstore/ui
$ npm install ./bookstore-ui-1.0.0.tgz
npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error
npm error While resolving: institution-portal@undefined
npm error Found: @angular/common@21.2.26
npm error node_modules/@angular/common
npm error   @angular/common@"21.2.26" from the root project
npm error
npm error Could not resolve dependency:
npm error peer @angular/common@"^22.2.0" from @bookstore/ui@1.0.0
npm error node_modules/@bookstore/ui
npm error   @bookstore/ui@"file:bookstore-ui-1.0.0.tgz" from the root project
npm error
npm error Fix the upstream dependency conflict, or retry this command with --force or --legacy-peer-deps to accept an incorrect (and potentially broken) dependency resolution.

npm stops the installation when the peer range is not satisfied. You can force it with --legacy-peer-deps, but as the error message says, the result may be a broken dependency tree. A rule in the Angular documentation backs this up: the application's Angular version must be the same as or newer than the version the library was built with. That is why a wide range (such as >=20.0.0) is not a convenience; it lets a package built with 22 be installed into an application on 20, and the error is left for run time.

Aligning the library's own version with Angular's major (@bookstore/ui 22.x for Angular 22) is a common choice. The upside is that users see at a glance which library version works with which Angular. The downside is that the library's own breaking changes get squeezed into Angular's schedule: you cannot break an API without Angular's major changing, or if you do, the number no longer says anything. In Bookstore we keep the library's own SemVer and state the supported Angular range explicitly in peerDependencies and in the CHANGELOG; moving to Angular's next major changes the peer range, so it is a major for the library too.

Publishing: the dist Folder, Scoped Packages and Registries

Publishing always happens from the build output. --dry-run shows the package contents and where and with which tag it would go, without touching the registry:

$ npm publish dist/bookstore/ui --dry-run
npm notice 📦  @bookstore/ui@1.0.0
npm notice Tarball Contents
npm notice 1.4kB README.md
npm notice 878B fesm2022/bookstore-ui-money.mjs
npm notice 1.1kB fesm2022/bookstore-ui-money.mjs.map
npm notice 2.4kB fesm2022/bookstore-ui.mjs
npm notice 1.6kB fesm2022/bookstore-ui.mjs.map
npm notice 915B package.json
npm notice 407B types/bookstore-ui-money.d.ts
npm notice 789B types/bookstore-ui.d.ts
...
npm warn publish This command requires you to be logged in to https://registry.npmjs.org/ (dry-run)
npm notice Publishing to https://registry.npmjs.org/ with tag latest and public access (dry-run)
+ @bookstore/ui@1.0.0

@bookstore/ui is a scoped package: its name starts with a scope. On npmjs scoped packages are treated as private by default and the first publish needs --access public; to avoid remembering that every time we put publishConfig.access into the source package.json, which is where "public access" in the output comes from. The repository field matters too: with trusted publishing, npm requires this URL to match the repository the workflow runs in exactly.

Scopes have another benefit: you can route a whole scope to a different registry. If you keep the package in an internal registry (GitHub Packages, Azure Artifacts or a server of your own), the consuming project's .npmrc defines the URL and credentials for that scope only; every other package keeps coming from npmjs:

@bookstore:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}

The token value is not written into the file; it is read from an environment variable. We tried a real publish against a local registry (Verdaccio in Docker) and saw how npm 11 behaves:

$ npm publish dist/bookstore/ui --registry http://localhost:18487/
...
npm error code ENEEDAUTH
npm error need auth This command requires you to be logged in to http://localhost:18487/
npm error need auth You need to authorize this machine using `npm adduser`

npm looks for credentials for the registry before sending any request; without them the publish never starts. Instead of creating a user account on the local registry, we limited publishing commands to --dry-run. In CI these credentials come from trusted publishing, which we will see below.

dist-tags, Prereleases and Deprecate

When you run npm install @bookstore/ui, npm does not pick the version directly; it installs the version the latest dist-tag points to. dist-tags are moving names given to versions. Angular's own package is a good example:

$ npm view @angular/core dist-tags
{
  ...
  'v20-lts': '20.3.35',
  'v21-lts': '21.2.26',
  next: '22.3.0-rc.0',
  latest: '22.2.6'
}

latest points to the stable version and next to the release candidate of the next version; for each old major there is also a vNN-lts tag pointing to the last patch of its LTS period. A project that stays on Angular 21 can get the latest fix in its own major with npm install @angular/core@v21-lts.

When you publish a prerelease, npm 11 protects you:

$ npm version 1.1.0-next.0 --no-git-tag-version --prefix projects/bookstore/ui
v1.1.0-next.0
$ npx ng build @bookstore/ui
$ npm publish dist/bookstore/ui --dry-run
npm error You must specify a tag using --tag when publishing a prerelease version.
$ npm publish dist/bookstore/ui --dry-run --tag next
npm notice Publishing to https://registry.npmjs.org/ with tag next and public access (dry-run)
+ @bookstore/ui@1.1.0-next.0

Publishing a prerelease without a tag is now an error. In the past this command made 1.1.0-next.0 the latest, and everyone running npm install @bookstore/ui got a prerelease. To manage tags afterwards there are the npm dist-tag commands (not run locally, since they need permissions on the registry):

npm dist-tag add @bookstore/ui@1.1.0 latest
npm dist-tag add @bookstore/ui@1.0.3 v1-lts
npm dist-tag rm @bookstore/ui next
npm dist-tag ls @bookstore/ui

When you find a serious bug in a version, the npm counterpart is deprecate. The package is not removed, but everyone who installs it sees a warning; giving an empty string as the message undoes the deprecation (these commands were not run locally either):

# all 1.0.x versions
npm deprecate @bookstore/ui@"1.0.x" "PriceTag shows the wrong currency in 1.0.x. Use 1.1.0 or later."
# undo
npm deprecate @bookstore/ui@"1.0.x" ""

On the user side, a deprecation looks like this; from a real deprecated package on npmjs:

$ npm view request@2.88.2 deprecated
request has been deprecated, see https://github.com/request/request/issues/3142

Publishing with Trusted Publishing

npm's counterpart to the model we saw with NuGet works the same way: you add a trusted publisher in the package's settings on npmjs (organization or user, repository, workflow file name, optional environment), and the workflow publishes with GitHub's OIDC token instead of a long-lived token. According to the npm documentation this needs npm CLI 11.5.1 and Node.js 22.14 or newer, and only GitHub's own hosted runners are supported. When a public package is published from a public repository, provenance is generated automatically too. Bookstore's workflow:

name: publish-ui

on:
  push:
    tags: ['ui/v*']

permissions:
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    defaults:
      run:
        working-directory: web
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org
          cache: npm
          cache-dependency-path: web/package-lock.json
      - run: npm ci
      - run: npx ng build @bookstore/ui

      - name: Publish (prereleases go to the next dist-tag)
        working-directory: web/dist/bookstore/ui
        run: |
          version=$(node -p "require('./package.json').version")
          if [ "ui/v$version" != "$GITHUB_REF_NAME" ]; then
            echo "Tag $GITHUB_REF_NAME does not match package version $version" >&2
            exit 1
          fi
          if [[ "$version" == *-* ]]; then
            npm publish --tag next
          else
            npm publish
          fi

The workflow is triggered by a ui/v* tag and first checks that the tag and the version in package.json match; if they diverge, publishing stops. If the version contains a hyphen (1.1.0-next.0) it publishes with the next tag, otherwise with latest; we carried npm's prerelease rule into the workflow. We ran the tag check and the tag choice locally with the same variables; the workflow was validated with actionlint and not run on GitHub. The npm documentation also recommends turning off token-based publishing entirely in the package settings once the trusted publisher is set up; then this workflow becomes the only way to publish.

In Production

Check the package contents in every release. Keeping the output of npm publish --dry-run or npm pack as a CI artifact lets you ask "why is there a 2 MB sourcemap in this release?" before publishing.

Widen the peer range only after testing. Before declaring the library works with Angular 22 and 23, try it in a test application built with both. The range you declare should be the range you actually test in CI.

Plan an LTS dist-tag. If you keep supporting an old major of the library, keep a v1-lts tag like Angular does and update it with every security fix that ships for 1.x; otherwise users on the old major cannot tell which version is current.

Take publishing rights away from people. Once trusted publishing is set up, turn off token-based publishing and revoke old automation tokens. Publishing rights sitting on a personal account mean that taking over the account takes over the package.

Common Pitfalls

Publishing the source folder instead of dist. The situation in the guess section: TypeScript sources and test files go to npm, and the application installing the package cannot find compiled code. Always run the publish command in the build output folder and leave no other path in CI.

Putting Angular into dependencies. The application ends up with two copies of Angular, and the error often seems to blow up in the application's code rather than the library's.

Leaving the peer range too wide. A range such as >=20 silently accepts installation on untested versions; npm's ERESOLVE check becomes useless.

Dropping a prerelease onto latest. npm 11 blocks publishing a prerelease without a tag, but a manual change such as npm dist-tag add @bookstore/ui@1.1.0-next.0 latest has the same result.

Linking secondary entry points with relative paths. An import such as ../money/src/money breaks the build or produces two copies of the same code in two entry points; use the package path inside the same library too.

A Small Experiment

★☆☆ Pack dist/bookstore/ui with npm pack, open the tarball and look at the exports field in package.json. Try importing @bookstore/ui/fesm2022/bookstore-ui.mjs from an application; what error do you get?

★★☆ Change the library's peer range to ^21.0.0 || ^22.2.0 and try installing it into the test project on Angular 21 again. Does the installation succeed? Does the library really work on Angular 21, and how would you verify it?

★★★ Add a second secondary entry point to the library, @bookstore/ui/testing, containing fake data for tests. Does the main package's bundle size change? When the consuming application uses only @bookstore/ui, does the testing code end up in its bundle?

The NuGet and npm packages are now versioned, validated and published without keys. In the last post of the series we look at where all these artifacts live: feeds and registries, container image tags, the hotfix flow, the release schedule and a support policy.