NX Monorepo for Angular Design Systems: Enterprise Guide 2025

Proven Publishing an Angular Component Library to NPM: Step-by-Step Guide 2025 Figure 1: An NX monorepo lets you manage your Angular design system libraries, consumer apps, and Storybook in one unified workspace
Why NX for Your Angular Design System?
A design system at scale involves more than one package: a UI component library, a tokens library, a utility library, a testing helpers library, an icons library — and the Storybook documentation site. Managing these as separate repos means synchronized versioning nightmares, duplicate CI setup, and broken cross-library refactors.
NX solves this with a structured monorepo that understands your dependency graph, caches build artifacts, and only rebuilds what changed. For design system teams, it's the difference between a 40-minute CI run and a 4-minute one.
NX Monorepo vs Standard Angular Workspace
| Feature | Angular Workspace | NX Workspace |
|---|---|---|
| Multiple apps | Yes | Yes |
| Multiple libraries | Yes | Yes |
| Incremental builds | No | Yes (computation caching) |
| Affected commands | No | Yes (nx affected) |
| Dependency graph visualization | No | Yes (nx graph) |
| Module boundary enforcement | Manual | ESLint rule, tags |
| Storybook integration | Manual | @nx/storybook generator |
| Release management | Manual | nx release |
| Remote caching | No | Nx Cloud |
Step 1: Create the NX Workspace
npx create-nx-workspace@latest my-design-system \
--preset=angular-monorepo \
--appName=docs \
--style=scss \
--bundler=esbuild \
--e2eTestRunner=none \
--nxCloud=skip
This creates:
my-design-system/
apps/
docs/ ← Storybook or documentation app
libs/ ← Design system libraries go here
nx.json ← NX configuration
tsconfig.base.json ← Shared TypeScript config + path aliases
Step 2: Generate Design System Libraries
NX uses generators to create consistently-structured libraries. Here's the full library taxonomy for a complete design system:
# UI Components — the primary publishable library
nx generate @nx/angular:library ui \
--directory=libs/ui \
--publishable \
--importPath=@my-ds/ui \
--prefix=ds \
--standalone \
--style=scss
# Design Tokens — CSS/SCSS/TS token files
nx generate @nx/angular:library tokens \
--directory=libs/tokens \
--publishable \
--importPath=@my-ds/tokens \
--buildable
# Utility functions — shared helpers
nx generate @nx/angular:library utils \
--directory=libs/utils \
--publishable \
--importPath=@my-ds/utils \
--standalone
# Icons — SVG icon components
nx generate @nx/angular:library icons \
--directory=libs/icons \
--publishable \
--importPath=@my-ds/icons \
--standalone
# Testing helpers — test utilities and harnesses
nx generate @nx/angular:library testing \
--directory=libs/testing \
--publishable \
--importPath=@my-ds/testing \
--standalone
Library Type Reference
| Library | --publishable |
--buildable |
Purpose |
|---|---|---|---|
ui |
✓ | — | Angular components, published to NPM |
tokens |
✓ | — | Design tokens, published to NPM |
utils |
✓ | — | Shared utilities, published to NPM |
icons |
✓ | — | Icon components, published to NPM |
testing |
✓ | — | Test harnesses, internal + published |
shared/types |
— | ✓ | Internal TypeScript interfaces |
Step 3: Enforce Module Boundaries
NX's module boundary rule uses project tags to enforce architectural constraints. First, tag your projects in project.json:
// libs/ui/project.json
{
"name": "ui",
"tags": ["scope:design-system", "type:library"]
}
// apps/docs/project.json
{
"name": "docs",
"tags": ["scope:docs", "type:app"]
}
// apps/consumer-app/project.json
{
"name": "consumer-app",
"tags": ["scope:app", "type:app"]
}
Then configure the ESLint rule in .eslintrc.json or eslint.config.js:
{
"rules": {
"@nrwl/nx/enforce-module-boundaries": ["error", {
"allow": [],
"depConstraints": [
{
"sourceTag": "scope:design-system",
"onlyDependOnLibsWithTags": ["scope:design-system"]
},
{
"sourceTag": "scope:app",
"onlyDependOnLibsWithTags": ["scope:design-system", "scope:shared"]
},
{
"sourceTag": "type:app",
"notDependOnLibsWithTags": ["type:app"]
}
]
}]
}
}
Now if any design system library tries to import from an app, the linter fails immediately — before it ever reaches code review.
Step 4: Set Up Storybook
NX has first-class Storybook support via the @nx/storybook plugin:
nx generate @nx/storybook:configuration ui \
--uiFramework=@storybook/angular \
--interactionTests=false
# Start Storybook for the ui library
nx storybook ui
NX generates .storybook/main.ts configured for the library:
// libs/ui/.storybook/main.ts
import type { StorybookConfig } from '@storybook/angular';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-a11y',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/angular',
options: {},
},
};
export default config;
Write stories directly in the library folder, co-located with components:
libs/ui/src/lib/button/
button.component.ts
button.component.scss
button.component.spec.ts
button.stories.ts ← co-located story
Step 5: Use Affected Commands in CI
This is where NX's ROI becomes undeniable. In your CI pipeline, replace full builds with affected builds:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
affected:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for nx affected to compare to base
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- name: Set NX base SHA
uses: nrwl/nx-set-shas@v4
- name: Lint affected
run: npx nx affected --target=lint --parallel=3
- name: Test affected
run: npx nx affected --target=test --parallel=3 --configuration=ci
- name: Build affected
run: npx nx affected --target=build --parallel=3
- name: Storybook build affected
run: npx nx affected --target=build-storybook --parallel=2
nrwl/nx-set-shas@v4 automatically sets NX_BASE and NX_HEAD environment variables for correct affected computation on PRs.
Step 6: Publish Libraries with NX Release
NX Release (introduced in NX 17) handles versioning and publishing for all publishable libraries:
# Dry run — see what would be released
nx release --dry-run
# Release with automatic version bump (conventional commits)
nx release
# Release specific version
nx release version 2.0.0
# Publish to NPM after versioning
nx release publish
Configure release in nx.json:
{
"release": {
"projects": ["ui", "tokens", "utils", "icons"],
"changelog": {
"workspaceChangelog": {
"createRelease": "github"
}
},
"version": {
"conventionalCommits": true
}
}
}
Conventional commits (feat:, fix:, chore:, BREAKING CHANGE:) drive automatic semantic version bumps — no manual version decisions.
Step 7: Visualize the Dependency Graph
NX builds a live dependency graph of your entire workspace:
nx graph
This opens an interactive browser visualization showing:
- Which libraries depend on which
- Which apps consume which libraries
- The impact of a change in any node
For design systems, this is invaluable for understanding the blast radius of breaking changes before publishing.
Migrating an Existing Angular Workspace
Already have a multi-project Angular workspace? Add NX without breaking anything:
npx nx@latest init
NX detects your existing Angular projects, generates nx.json, and adds caching. Your existing ng commands continue working — NX is purely additive.
When NOT to Use NX
NX adds orchestration overhead. Don't use it if:
- You have a single Angular app with one or two small utility libraries
- Your team has fewer than 3 developers
- You're building a proof of concept that will never scale
- Your CI pipeline already caches effectively and runs in under 5 minutes
For a single-package design system, a standard Angular workspace with a standalone library project is simpler and faster to set up.
If your real driver is independent deployment per team rather than build speed, a monorepo alone will not give you that. See the Angular module federation step-by-step guide for how micro frontends fit into an Nx workspace.
The Payoff
A mature NX monorepo for a design system gives you:
- Speed: Only build and test changed libraries — 80%+ CI time reduction is common
- Safety: Module boundaries prevent architectural drift before it becomes technical debt
- Consistency: NX generators enforce the same structure across all libraries
- Visibility: The dependency graph makes impact analysis obvious
- Automation: NX Release ties conventional commits directly to published NPM versions
Start with two libraries (UI + tokens) and add more as your system grows. NX scales gracefully from 2 libraries to 200.