Angular Monorepos: CLI Workspaces, Libraries and Nx [2026]
The second Angular app in a company usually starts by copying the first one's shared/ folder. A year later there are four copies of the date pipe, three slightly different button components and no way to fix a bug everywhere at once. A monorepo with shared libraries fixes the duplication, but it raises new questions: how libraries are built and imported, how to stop a feature from reaching into another team's internals, and how to keep CI fast when one repository holds ten projects.
This is lesson 14.8 of the Angular Tutorial. It follows lesson 14.7 on Angular Elements and extends the single-project layout from lesson 1.2. You'll learn how multi-project Angular CLI workspaces work, how to structure, build and publish a library, how secondary entry points and path mappings fit together, and what Nx adds on top: a project graph, affected commands, caching and enforced module boundaries.
Three ways to share code #
| Approach | How code is shared | Good for |
|---|---|---|
| Single project with folders | Relative imports inside one app | One app, one team |
| Angular CLI multi-project workspace | Libraries under projects/, imported through path mappings |
A few apps and libraries, or libraries published to npm |
| Nx workspace | Libraries imported from source, with a project graph and task orchestration | Many apps and libraries, several teams, slow CI |
Both monorepo options keep one node_modules and one Angular version for every project, which is most of the benefit: an upgrade happens once, and a shared library change is tested against every consumer in the same pull request.
Angular CLI multi-project workspaces #
Start without a default application, then add projects:
ng new acme --no-create-application
cd acme
ng generate application shop
ng generate application admin
ng generate library ui
New projects go into projects/ (the newProjectRoot setting), and each gets an entry in angular.json with "projectType": "application" or "library". Target a project by name:
ng serve shop
ng test ui
ng build admin
Each project has its own build, test and serve targets, and library tests run with the same Vitest setup as applications (lesson 11.1). Checking the whole workspace therefore means one command per project, or a script that loops over them. That cost grows with every project you add, and it is exactly what Nx's affected commands remove later in this lesson.
What a library contains #
projects/ui/
├── ng-package.json # ng-packagr config: output dir, entry file
├── package.json # name, version, peerDependencies
├── tsconfig.lib.json
├── tsconfig.lib.prod.json # "compilationMode": "partial"
└── src/
├── public-api.ts # the library's public surface
└── lib/
├── button/
└── date-range-pipe/
The library's angular.json entry uses the @angular/build:ng-packagr builder, which produces the Angular Package Format (APF) output that npm consumers expect. Production builds use partial compilation, which is the recommended format for npm: the consuming app's compiler finishes the compilation, so the package isn't tied to the exact Angular version it was built with.
The public API is the contract #
Only what public-api.ts exports is available to consumers:
// projects/ui/src/public-api.ts
export * from './lib/button/button';
export * from './lib/date-range-pipe/date-range-pipe';
export { UI_CONFIG, provideUi } from './lib/config';
Treat this file as the boundary. Everything not exported is an implementation detail you can refactor freely. Exporting whole folders with export * from deep internals defeats the purpose.
Configuring a library with a provider function #
Libraries that need configuration should follow the same pattern as Angular's own provideRouter() and provideHttpClient(): an injection token plus a provideX() function that apps call in app.config.ts.
// projects/ui/src/lib/config.ts
export interface UiConfig {
density: 'comfortable' | 'compact';
dateFormat: string;
}
export const UI_CONFIG = new InjectionToken<UiConfig>('UI_CONFIG', {
factory: () => ({ density: 'comfortable', dateFormat: 'mediumDate' }),
});
export function provideUi(config: Partial<UiConfig>): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: UI_CONFIG, useValue: { density: 'comfortable', dateFormat: 'mediumDate', ...config } },
]);
}
The token's factory gives sensible defaults, so the library works without any setup, and each app overrides only what it needs. Library components read the value with inject(UI_CONFIG). Returning EnvironmentProviders also stops anyone from adding the configuration to a component's providers by mistake.
Path mappings and building first #
ng generate library adds a path mapping to the workspace tsconfig.json, pointing at the built output:
{
"compilerOptions": {
"paths": {
"ui": ["./dist/ui"],
"ui/*": ["./dist/ui/*"]
}
}
}
Apps import ui as if it were an npm package. Because the mapping points at dist/, the library must be built before the app can compile against it. During development, keep a watch build running in a second terminal:
ng build ui --watch
ng serve shop
The generator maps only the package root. The ui/* wildcard shown above is what you add for secondary entry points.
Secondary entry points #
Large libraries benefit from sub-packages such as ui/table and ui/date. A secondary entry point is a folder inside the library with its own ng-package.json:
projects/ui/table/
├── ng-package.json # { "lib": { "entryFile": "src/public-api.ts" } }
└── src/
├── public-api.ts
└── table.ts
import { Button } from 'ui';
import { DataTable } from 'ui/table';
Each entry point is built and imported separately, so a consumer that uses only buttons never compiles the table code, and the entry points form natural, reviewable boundaries.
Publishing #
ng build ui --configuration production
cd dist/ui
npm publish
Put @angular/core and @angular/common in the library's peerDependencies, not dependencies, so consumers don't get a second copy of Angular. You don't have to publish to use a library inside the same workspace; publishing matters only when other repositories consume it.
What Nx adds #
Nx is a build system and monorepo tool that works with Angular through its @nx/angular plugin. You can start a new workspace with npx create-nx-workspace@latest or add Nx to an existing CLI workspace with npx nx@latest init, after which commands look familiar: nx serve shop, nx test ui.
Project graph #
Nx analyses imports and configuration to build a graph of which project depends on which:
nx graph
The graph is the foundation for everything else Nx does.
Affected commands #
In CI, Nx compares the current change against a base commit, maps changed files to projects, adds every project that depends on them, and runs tasks only for that set:
nx affected -t lint test build --base=origin/main --head=HEAD
nx graph --affected
Change a pipe in ui, and shop and admin are tested; change only admin, and shop is skipped.
Caching #
Mark side-effect-free targets as cacheable in nx.json:
{
"targetDefaults": {
"build": { "cache": true },
"test": { "cache": true },
"lint": { "cache": true }
}
}
Nx hashes each task's inputs (source files, configuration, dependencies). On a cache hit it restores the terminal output and the produced files instead of running the task. The cache is local by default; a remote cache, such as Nx Cloud, shares results between CI runs and developers. Don't cache tasks with side effects, such as e2e tests that call a live backend.
Module boundaries #
Tag each project in its project.json:
{ "name": "shop-feature-cart", "tags": ["scope:shop", "type:feature"] }
Then enforce which tags may depend on which with the @nx/enforce-module-boundaries ESLint rule:
// eslint.config.mjs
{
files: ['**/*.ts'],
rules: {
'@nx/enforce-module-boundaries': ['error', {
depConstraints: [
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
{ sourceTag: 'scope:shop', onlyDependOnLibsWithTags: ['scope:shared', 'scope:shop'] },
{ sourceTag: 'type:ui', onlyDependOnLibsWithTags: ['type:ui', 'type:util'] },
],
}],
},
}
Now an import from shop into admin internals, or from a UI library into a feature, fails lint in the pull request instead of surviving until someone tries to extract the code.
Generators #
Nx generators scaffold projects and wire up tags, path aliases and test configuration:
nx g @nx/angular:application apps/shop
nx g @nx/angular:library libs/shop/feature-cart --tags=scope:shop,type:feature
Nx's Angular libraries are typically imported from source through path aliases, so apps compile library code directly and there is no separate library build to keep running in watch mode. Mark a library buildable or publishable only when you need that.
CLI workspace or Nx? #
| Question | Angular CLI workspace | Nx |
|---|---|---|
| Number of projects | A handful | Dozens or more |
| Setup and tooling to learn | Nothing beyond the CLI | Nx concepts, project.json, plugins |
| Library consumption | Build first, import from dist |
Import from source; build only publishable libs |
| CI scaling | Build and test everything | affected plus local and remote caching |
| Dependency rules | Code review | Enforced by lint (enforce-module-boundaries) |
| Mixed stacks (Node API, React) | Angular only | One graph for all of them |
| Upgrades | ng update |
nx migrate, which also runs Angular migrations |
Start with a CLI workspace when you have one or two apps and a shared library. Move to Nx when CI time, unclear dependencies between teams or the number of projects becomes the bottleneck; nx init keeps the move incremental.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
App can't resolve ui |
Path mapping points to dist/ui, which doesn't exist yet |
ng build ui (or --watch) before serving the app |
ui/table not found |
Generator only mapped the package root | Add a "ui/*" wildcard path |
| Two copies of Angular in a consumer | @angular/* listed in dependencies |
Move them to peerDependencies |
Consumers import from lib/... deep paths |
Internals reachable through path aliases | Export only via public-api.ts; enforce with lint |
nx affected runs everything in CI |
Base is the start of history or a shallow clone | Fetch enough history; use the last successful main commit as base |
| Stale results after a config change | Input not part of the cache hash | Add it to the target's inputs in nx.json |
What's next #
Lesson 14.9 is the final lesson of the tutorial. It looks at architecture inside a single app, from smart and presentational components to feature folders, lazy routes and DI scoping, and then shows how Angular DevTools helps you verify that the structure behaves the way you designed it.
Try it yourself #
Create a workspace with ng new acme --no-create-application, add an app and a ui library with one component, export it from public-api.ts, build the library in watch mode and use it from the app. Then add a secondary entry point and import it.
list_projectsYour three workspaces each have one application and a shared/ folder with overlapping components, so the merge pays off either way. Given your CI times, Nx is the better target. A merged CLI workspace would build and test all three apps on every change, roughly tripling a 20-minute run, while nx affected -t lint test build plus caching only runs what a change touches. Suggested path: (1) start one workspace, then use nx import to bring each repo in with its history; (2) extract the duplicated components into libs/shared/ui tagged scope:shared,type:ui; (3) tag each app’s features with its own scope and turn on @nx/enforce-module-boundaries so apps can share only through scope:shared; (4) enable caching for build, test and lint, and add a remote cache once CI is stable.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


