Skip to content

Building an Angular Library with multiple entry points

An Angular library lets you share code between multiple projects. For a larger library it's recommended to use subentry points. We'll build a new Angular library from scratch, add multiple subentry points with dependencies between them and add a showcase app that uses the library.

Published
Mar 08, 2022
Updated
Jan 20, 2024
Reading time
21 min read

Update January 2024

The article and demo have been updated to Angular 17. If you use a previous version of Angular, read the previous version of this article.

The Angular CLI has a generate library command to generate a new Angular library. This works fine for smaller libs but as your library grows it becomes more difficult to maintain and may have negative performance impacts. Then it's time to split the library into multiple entry points (known as secondary entry points or subentry points).

Unfortunately, the Angular CLI does not have a command for creating additional entry points and documentations or articles related to this topic are rare.

So let's build a new Angular library from scratch and see how we can create secondary entry points.

What are entry points

A library always has a main entry point which is the name of the library like react (without an npm scope) or @angular/core (with the npm scope @angular). In a newly created Angular library that's the only entry point that you have, and it exports all of your modules, components, services, etc.

An npm package can have additional entry points, called secondary entry points (or subentry points). An example is Angular Material which has a separate entry point for each component:

import { MatButtonModule } from "@angular/material/button";
import { MatCardModule } from "@angular/material/card";

Why creating entry points

You may ask why such subentry points are useful. I won't go into detail, there are articles that explain that better, but the main advantages are:

  • Better tree shaking if you use UMD libraries
  • Improved code splitting when lazy loading modules
  • Dependency handling a bit more flexible
  • Better separation of features in your lib

What we'll build

In this article we will build a component library (or actually we'll pretend because we will not build any useful component) called ui-sdk for the fake company mycompany. We'll call the library package @mycomp/ui-sdk.

Our library will have multiple subentry points, and they will also depend on each other. We'll add an Angular app in our workspace as a showcase application. It will import the library and use its components.

OK, let's start implementing the library.

Lib Implementation

Create workspace

First we need to create a new Angular workspace using the Angular CLI. A workspace can contain multiple applications and libraries, but we'll only create one library for now.

ng new ui-sdk --create-application false
cd ui-sdk

I call the workspace ui-sdk, but you can name it whatever you want. This is only the name of the workspace and it doesn't have to be the same as the name of the library.

We use the --create-application flag to prevent the CLI from creating an application. Right now we just want to build a library and don't need an app yet. However, we'll add a showcase app later.

Create library

Now that we have an empty workspace we create the library. I'll call the library @mycomp/ui-sdk. The npm scope (here @mycomp) is optional but recommended when you have multiple libraries or other packages that you want to publish.

# Make sure to run this inside the workspace
ng generate library @mycomp/ui-sdk

Now we have our workspace and our library. Let's take a closer look at what the CLI has generated for us.

Project structure

Below you can see the basic structure of the project that the CLI has generated for us (I omitted the irrelevant files). In the root there is the angular.json that configures the workspace and the package.json that contains all dependencies.

.
├── projects/
│   └── mycomp/
│       └── ui-sdk/
│           ├── src/
│           │   ├── lib/                     # (1)
│           │   │   ├── ui-sdk.component.ts
│           │   │   └── ui-sdk.service.ts
│           │   └── public-api.ts            # (2)
│           ├── ng-package.json              # (3)
│           └── package.json                 # (4)
├── angular.json
└── package.json

Now let's go into detail of some files:

Library Code (1)
This folder contains the code of the library, currently a component and service. This is the main and only entry point of your library right now.

Public API (2)
The file contains the public API of the library. It exports all members that should be available to the outside world. That's the Angular component and service inside the src/lib/ folder.

ng-package.json (3)
The file contains the configuration for ng-packagr. It specifies the path of the build output and the entry file which points to src/public-api.ts.

package.json (4)
This is the package.json of your library (not to be confused with the package.json of your Angular workspace in the root folder). Here you specify the name, version and dependencies of your library.

Create subentry point

Next we want to create a secondary entry point. We want to create an entry point to handle internationalization like translations and we call it i18n. In a real world example it may export a translation service, a pipe and directive but for demo purposes we'll just add a simple dummy pipe.

The entry point has its own folder i18n that is located under projects/mycomp/ui-sdk. You cannot create it in the src/lib/ subfolder because then your final import would be @mycomp/ui-sdk/src/lib/i18 instead of @mycomp/ui-sdk/i18n.

Inside our new entry point we add a src folder that contains the code of the entry point. Here for example we create translation pipe. You can add other components, services, injection tokens, types and whatever else you need.

.
└── projects/
    └── mycomp/
        └── ui-sdk/
            ├── i18n/
            │   └── src/
            │       ├── translate.pipe.spec.ts
            │       └── translate.pipe.ts
            └── src/...

Our pipe is just a dummy pipe that returns the input string. In a real world example it would return the translated string.

projects/mycomp/ui-sdk/i18n/src/translate.pipe.ts
import { Pipe, PipeTransform } from "@angular/core";

@Pipe({
  name: "translate",
  standalone: true,
})
export class TranslatePipe implements PipeTransform {
  transform(value: string): unknown {
    // Here you would translate the key
    return `${value} (translated)`;
  }
}

Next we need to create a public API file. This file exports all members of your subentry point that should be usable by applications and other subentry points. In our case we just export the TranslatePipe. You should export all public Angular elements (modules, components, pipes, ...) and all TypeScript types that are used by any of these elements. You don't need to export components that are used only internally and you should not export spec files. ng-packagr will throw an error if you forget to export something.

Create the public-api.ts in the i18n folder and export the pipe:

projects/mycomp/ui-sdk/i18n/public-api.ts
export * from "./src/translate.pipe";

Then we need to tell ng-packagr that this is a subentry point. We do that by creating a ng-package.json file inside the i18n folder with the following content:

projects/mycomp/ui-sdk/i18n/ng-package.json
{
  "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json",
  "lib": {
    "entryFile": "public-api.ts"
  }
}

The file tells ng-packagr that it's a subentry point and the entryFile property specifies what the subentry point exports.

Omitting entryFile

If you omit the entryFile property in the JSON above, ng-packagr would automatically look for a file src/public_api.ts relative to the ng-package.json file. That means that you could move the public API file into the src folder, rename it to public_api.ts (note the kebab-case vs snake-case naming) and remove the entryFile property from the ng-package.json file. Feel free to do this if you want, but I prefer the way described above to have consistent file names (Angular uses kebab-case everywhere else). You can also keep the file name bust just move the file into the src folder if you prefer.

That's it, we now have a subentry point. Let's quickly review our project structure again. This is how your project should look now:

.
└── projects/
    └── mycomp/
        └── ui-sdk/
            ├── i18n/
            │   ├── src/
            │   │   ├── translate.pipe.spec.ts
            │   │   └── translate.pipe.ts
            │   ├── ng-package.json
            │   └── public-api.ts
            └── src/...

Build the lib

Before we move on, let's build the library:

npm run build

This will generate the main entry point and our new secondary entry point i18n. If the command runs successfully, the output should look something like this:

Building Angular Package

------------------------------------------------------------------------------
Building entry point '@mycomp/ui-sdk'
------------------------------------------------------------------------------
✔ Compiling with Angular sources in Ivy partial compilation mode.
✔ Writing FESM bundles
✔ Copying assets
✔ Writing package manifest
✔ Built @mycomp/ui-sdk

------------------------------------------------------------------------------
Building entry point '@mycomp/ui-sdk/i18n'
------------------------------------------------------------------------------
✔ Compiling with Angular sources in Ivy partial compilation mode.
✔ Writing FESM bundles
✔ Built @mycomp/ui-sdk/i18n

------------------------------------------------------------------------------
Built Angular Package
 - from: /ui-sdk/projects/mycomp/ui-sdk
 - to:   /ui-sdk/dist/mycomp/ui-sdk
------------------------------------------------------------------------------

You can see that it built the main entry point @mycomp/ui-sdk and the secondary entry point @mycomp/ui-sdk/i18n.

More entry points

Now you can add an additional entry point in the same way. Let's create an entry point button that provides a button component. Create the button folder and src folder, create a component, add a public API and a ng-package.json file.

.
└── projects/
    └── mycomp/
        └── ui-sdk/
            ├── button/
            │   ├── src/
            │   │   ├── button.component.css
            │   │   ├── button.component.html
            │   │   ├── button.component.spec.ts
            │   │   └── button.component.ts
            │   ├── ng-package.json
            │   └── public-api.ts
            ├── i18n/...
            └── src/...

Run the build again, and you should now have three entry points.

Deps between entry points

You will likely have some shared entry points that are used by multiple subentry points. In our example the entry point i18n may be used by other entry points. Let's import the TranslatePipe in our ButtonComponent.

The good news is it works automatically. When you import one entry point in another entry point, ng-packagr will automatically detect this and handle it properly. The bad news is that this will not work in your IDE. There are some additional steps you need to take to make it work.

First we need to adjust the tsconfig.json of the workspace (in the root folder). Remove the existing paths mapping and add the following:

tsconfig.json
{
  "compilerOptions": {
    "paths": {
-     "@mycomp/ui-sdk": [
-       "./dist/mycomp/ui-sdk"
-     ]
+     "@mycomp/ui-sdk/*": [
+       "./projects/mycomp/ui-sdk/*",
+     ],
+     "@mycomp/ui-sdk": [
+       "./projects/mycomp/ui-sdk",
+     ]
    },
    // ... (other props omitted)
}

This tells TypeScript that when we import something from @mycomp/ui-sdk (or a subentry point) where to look for the actual code.

For the import to work, we need an index.ts in each entry point folder that re-exports the public API. Create the file in both folders:

projects/mycomp/ui-sdk/i18n/index.ts
export * from "./public-api";
projects/mycomp/ui-sdk/button/index.ts
export * from "./public-api";

Note: If you don't want to have a separate file you can rename the existing public-api.ts file to index.ts and adjust the import in the ng-package.json file. That only works if your public-api.ts file is not inside the src folder.

Finally, we can import the TranslatePipe in our ButtonComponent:

projects/mycomp/ui-sdk/button/src/button.component.ts
import { TranslatePipe } from "@mycomp/ui-sdk/i18n";

@Component({
  // ...
  imports: [CommonModule, TranslatePipe],
})
export class ButtonComponent {}

That's it, we now have a dependency between our two secondary entry points. When you build the library again you will see that the button entry point is built after the i18n entry point because it depends on the i18n entry point.

Make sure that you don't introduce circular dependencies between entry points. But don't worry, ng-packagr will detect circular dependencies and throw an error.

Tests

When you run the tests with npm test you will notice that only the UI SDK Component tests (the one of the main entry point) are executed. The tests of our two secondary entry points are not executed. That's because of the configured source root. Adjust the path in the angular.json file as shown below:

angular.json
{
  "projects": {
    "@mycomp/ui-sdk": {
-     "sourceRoot": "projects/mycomp/ui-sdk/src",
+     "sourceRoot": "projects/mycomp/ui-sdk",
    }
  }
}

Run npm test again and all tests should be executed now.

Code Coverage

Angular supports code coverage out of the box by passing --no-watch --code-coverage to the ng test command. After you have configured the sourceRoot property as described previously, you can run the tests with coverage:

ng test --no-watch --code-coverage

Main entry point

Our main entry point still has the default structure and exports a component and service. Let's clean this up a bit.

We move the component and service into the src folder and delete the lib folder. Then we move the public-api.ts one level up into the ui-sdk folder and create an index.ts file that re-exports the public API. Adjust all imports and update the entryFile property in the ng-package.json file.

The main entry point now uses a similar setup as the subentry points.

.
└── projects/
    └── mycomp/
        └── ui-sdk/
            ├── button/...
            ├── i18n/...
            ├── src/
            │   ├── ui-sdk.component.ts
            │   └── ui-sdk.service.ts
            ├── index.ts
            ├── ng-package.json
            └── public-api.ts

As we already have subentry points do we still need the main entry point, or can we remove it? You have three options:

No main entry point:
You can move all components and services into multiple subentry points and don't use the main entry point. If your library provides a lot of components that's in my opinion the best way. Every component will have its own subentry point and there is no confusion whether a component is part of the main entry point or a subentry point.

Note that ng-packagr does not accept an empty entry point, you always need to export something. As a workaround you can export a dummy variable like a constant with your library name:

projects/mycomp/ui-sdk/public-api.ts
export const MY_COMP_UI_SDK = "@mycomp/ui-sdk";

Then you can delete your src folder.

Main + Subentry:
You can keep the main entry point as actual main entry point that exports most of your components. And you can add a subentry point for additional features, like a testing utilities. This is a good approach if your library provides one specific functionality, like a logger library, and does not provide many components.

Your subentry can import the main entry point (using @mycomp/ui-sdk) and use those components, directives, etc. Your main entry point cannot depend on that subentry point because that would lead to circular dependencies.

On thing mention here: for this scenario it's probably better to keep the initial folder structure of your main entry point with src/lib/ instead of doing the cleanup described previously. That's because the Angular CLI expects this exact structure and doesn't play well with the cleanup (we'll cover that problem later in this article).

Main + Subentries:
You can keep the main entry point and add multiple subentry points for additional features. I don't have a good use case in mind for this approach, so you should probably move everything into subentry points if you don't see a clear benefit of the main entry point.


Now you have a clean main entry point and your library is ready to provide many more subentry points.

CLI Generate command

When you want to add a new component, directive, pipe or service you normally can use the Angular CLI and its generate command. Unfortunately, the CLI is not designed to work with subentry points. When running a command like this:

ng generate component dropdown

The CLI will use the source root path (configured in angular.json) and will append lib/ to the path. This lib prefix is hard-coded in the code and cannot be changed. This will result in a completely wrong entry point structure. To bypass this behavior you need to pass the --path option whenever you want to create a new component.

For example, to create a new dropdown component in its own subentry point you can use the following command:

# Or use the short version: ng g m ...
ng generate component dropdown --flat --path projects/mycomp/ui-sdk/dropdown/src

You can omit the --flat option if you want to create a subfolder (useful if your subentry point has a lot of components).

Dependency Management

Your library may have dependencies that are only used by a specific subentry point. Unfortunately, subentry points cannot have their own (peer) dependencies. Therefore, you need to add the dependency to the main package.json of your library.

You have two options. You can add the dependency as a normal peer dependency. Every user of your library will need to install the dependency even if the subentry point is not used. This increases the installation time and disk usage (does it matter?) but the dependency will be tree shaken away when the subentry point is not used.

The second option is to mark the peer dependency as optional. The user does not have to install it if the subentry point is not used. When the subentry point is used, but the dependency has not been installed, it will fail to compile. I recommend this way when you have a dependency that is only used by one subentry point and that entry point is not used by all users.

Here is an example of making a peer dependency optional:

projects/mycomp/ui-sdk/package.json
{
  "peerDependencies": {
    "some-library": "^1.0.0" // add the peer dependency
  },
  "peerDependenciesMeta": {
    "some-library": {
      "optional": true // mark it as optional
    }
  },
}

That's it, we now have an Angular library with multiple entry points and dependencies between them. Next, we want show how great our library is by creating a showcase app in the same Angular workspace that uses our library.

App Implementation

We now want to add an application in the existing workspace that uses the library. This could be a real application or just a showcase / demo for our library. This section only applies to apps in the same workspace as the library. External applications (in their own workspace) will use the library via npm dependency.

Create app

When we created the workspace we disabled the creation of an application. We now generate a new app:

ng generate app showcase

You can already run the app with npm start and build it with ng build showcase. This works out of the box.

Use the lib

We want to use our library in our newly generated app. For that we need to tell TypeScript where to find the library which is done with the paths mapping in the tsconfig file.

Our existing paths mapping (in the root tsconfig.json) points to the source of the library. For apps, Angular recommends using the built library instead of the source. That's because an application is built in a different way than a library (an app uses @angular-devkit/build-angular while a lib is built with ng-packagr) and if you use the source of the library in your app, it will compile the library code the same way as the app code. This could lead to problems.

You could update the existing paths mapping to point to the built library instead (the dist folder), that's actually what Angular initially configured when we added the library. This would technically work when building the library or running the app. But it will negatively impact your developer experience. When you update an entry point (e.g. you add a new component), you need to rebuild the library and wait until your IDE (and the Angular Language Service Plugin) picks up the changes, before you can use the changes in another entry point. And using Go to implementation on an import path navigates to the dist folder instead of the source (though that use case may be rare).

An alternative would be to remove the existing paths mapping and create two new mappings, one in the tsconfig.lib.json that points to the actual source code and one in the tsconfig.app.json that points to the built library (because that's what you need to do for the app). This works when running or building the lib/app but will completely ruin your dev experience when using Visual Studio Code. That's because VS Code will only detect files called tsconfig.json (in the root and any subfolder) but will completely ignore files with a different name. So it will not recognize your paths and will show an error for all library imports. If you are using WebStorm this would be a viable alternative because that IDE supports all tsconfig files.

So finally, what you should do: keep the existing paths mapping and add a new one in the tsconfig.app.json that points to the built library:

projects/showcase/tsconfig.app.json
{
  "compilerOptions": {
+   "paths": {
+     "@mycomp/ui-sdk/*": [
+       "./../../dist/mycomp/ui-sdk/*"
+     ],
+     "@mycomp/ui-sdk": [
+       "./../../dist/mycomp/ui-sdk"
+     ]
+   }
  }
}

Now what happens with this new config? When you...

  • build the lib: ng-packagr ignores all of your paths mappings anyway. It will handle imports between entry points automatically for you.
  • build the app: it will use the new mappings above which point to the built library. That's exactly what Angular recommends. The existing paths in the root tsconfig.json file will be ignored.
  • work in VS Code: it will use the mappings of the root tsconfig.json file which point to the library source code - for both, the library and the app. That provides the best developer experience for your imports.

Now go ahead and use the library by importing a component from the main entry point or a subentry point:

projects/showcase/src/app/app.component.ts
import { ButtonComponent } from "@mycomp/ui-sdk/button";

@Component({
  // ...
  imports: [CommonModule, ButtonComponent],
})
export class AppComponent {}

Next we want to start our application with ng serve. This requires that we build the library first.

NPM Scripts

When you are working on the library you may want to run the showcase to immediately see the changes. As the showcase app depends on the built version of the library and not directly on the source, you need to make sure that the library is built before running the app with ng serve. Whenever you make a change, you should rebuild the library.

Rebuilding the library is as easy as passing --watch to the ng build command. Unfortunately, as far as I know there is no easy way provided by the Angular CLI to build the library and wait for it to finish before serving the app.

That's why we need to install three additional packages:

npm install -D npm-run-all wait-on rimraf

With the first one you can run multiple npm scripts in parallel (and sequential), with the second one you can wait for a file to exist before you proceed. Here is the setup I use:

package.json
{
  "scripts": {
    "start": "npm-run-all clean --parallel lib:watch showcase:start-waiton",
    "build": "run-s lib:build showcase:build",
    "test": "run-p lib:test showcase:test",
    "lib:build": "ng build @mycomp/ui-sdk",
    "lib:watch": "ng build @mycomp/ui-sdk --watch --configuration development",
    "lib:test": "ng test @mycomp/ui-sdk",
    "showcase:start-waiton": "wait-on dist/mycomp/ui-sdk/package.json && npm run showcase:start",
    "showcase:start": "ng serve showcase",
    "showcase:build": "ng build showcase",
    "showcase:watch": "ng build showcase --watch --configuration development",
    "showcase:test": "ng test showcase",
    "clean": "rimraf dist"
  }
}

Let's take a look at some of these scripts:

  • start: It first cleans the dist folder, then it runs lib:watch which will build the library and watch for changes (rebuilding it when a file changes). At the same time, it runs wait-on to check if the library build is finished (by checking if the package.json in the output folder exists) before serving the app. When you update the library it will automatically rebuild it and then rebuild the app.
  • build: It runs both builds sequentially using run-s
  • test: It runs both tests (lib + app) in parallel using run-p

run-s and run-p are provided by npm-run-all.

Now you can start the showcase app with npm start and make changes to the library. The app will automatically rebuild and reload.

Storybook

If you are building a component library I recommend that you use Storybook instead of creating a showcase app. The setup is not always easy, it has sometimes painful breaking changes and annoying bugs. But despite everything it's a powerful tool that makes it easier to develop components, has great features for creating an interactive showcase and allows you to document all components with Markdown / MDX. Give it a try!

Demo

The source code of the @mycomp/ui-sdk library and the showcase app is available on GitHub:
github.com/rothsandro/angular-library-example

Summary

Hooray 🎉 We built a library!

We now have an Angular workspace with a library and a showcase app. We created multiple entry points with dependencies between them and used the library in our showcase app. With this setup you are now ready to build your library with great components.

There are many more topics to talk about, like styling and asset handling. But we must come to an end now. I hope you enjoyed this article and you learned something new.