A build-time white-labeling system for Angular using a single Nx monorepo. Client-specific JSON configs drive which features are included and which version of each feature is loaded β either from local workspace source or from a private npm registry.
Most enterprise Angular applications pay a "Hidden Architectural Tax". They wait until runtimeβin the user's browserβto decide which features to load, leading to bundle bloat, fragile runtime errors, and invisible complexity.
NACS (Nx Angular Composable Shell) is built on the premise of "Pushing Decisions Left". By moving composition logic out of the browser and back into the compiler, we create hermetically sealed, client-specific artifacts that contain exactly what the user is licensed forβand nothing more.
This project is built around three core tenets:
- Simplicity β Move complexity from the browser to the build pipeline. Declarative JSON configurations provide a single, reviewable, and type-safe source of truth for application composition.
- Reuse β Treat features as independently versioned contracts. Product teams contribute features through well-defined extension interfaces, allowing multiple versions of the same feature to exist in production simultaneously for different clients.
- Decoupling β The shell does not know what it could contain; it only knows what it does contain. This separation enables aggressive tree-shaking and physical security guarantees against code leakage.
This repository is a Proof of Concept (POC), not a production-ready framework. It demonstrates architectural patterns such as build-time route generation, generated composition code, and strict dependency governance, but it is intentionally experimental.
This repo is paired with a LinkedIn article series. The five-part series will be linked here as each part is published.
- NACS Part 1: The Hidden Architectural Tax β Why Build-Time Composition Wins
- NACS Part 2: One Config File. One Client. Zero Leaked Features
- NACS Part 3: A Platform Nobody Uses Is Just a Tax
- NACS Part 4: The Shell That Doesn't Know What It Contains
- NACS Part 5: You Aren't Building a Tool. You're Building a Platform
NACS transforms your monorepo from a collection of libraries into a governed, scalable platform. The process "pushes decisions left" through the following steps:
- A client config JSON (
configs/*.json) declares which feature libraries to include and optionally pins each to a specific published version. - A pre-build script reads the config, installs any versioned packages as npm aliases, then discovers each library's routing metadata and slot contributions from the library's own
package.json(nacs-contributionsfield). - The script generates
apps/shell/src/app/app.composition.generated.tsβ a single file containing both the top-level feature routes (generatedRoutes) and any extension point arrays (e.g.extAdmin). - Angular/esbuild compiles the shell. Any feature not referenced in the generated routes file is fully tree-shaken from the output bundle. The result is a custom-tailored, client-specific production bundle.
After cloning, run:
npm install
npm run setupnpm run setup does two things:
- Registers the git hooks (
git config core.hooksPath .githooks) so thatpost-mergeandpost-checkoutkeep generated files in sync automatically. - Runs
nx syncto generate any files that are not committed to the repo (see Generated Files below).
These steps are intentionally separate from
npm installso they are not silently run in CI environments.
Two files are auto-generated and not committed to the repository:
| File | Generator | When to regenerate |
|---|---|---|
apps/shell/src/app/app.composition.generated.ts |
nx run shell:prepare-build |
After changing configs/*.json or a feature's nacs-contributions |
libs/build-tools/nacs-package.schema.json |
nx sync |
After adding or modifying an extensionPoints declaration in any libs/*/package.json |
The git hooks installed by npm run setup regenerate these files automatically on git pull and git checkout. CI enforces freshness via nx sync:check.
Uses configs/client-dev.json. No npm installs β imports come directly from local workspace source.
npx nx run shell:serve:devTargets a specific client config by name. Versioned features are fetched from the registry and installed as npm aliases before the Angular build runs.
npx nx run shell:build:production --client=client-prod-v1Replace client-prod-v1 with any filename (without .json) from the configs/ directory.
After a production build, serve the output with:
npx http-server-spa dist/apps/shell/browserConfigs live in configs/ and follow the schema defined in configs/client-config.schema.json.
Example β local dev (configs/client-dev.json):
{
"$schema": "./client-config.schema.json",
"clientId": "dev",
"features": [{ "module": "@nacs/feature-dashboard" }, { "module": "@nacs/feature-a" }, { "module": "@nacs/feature-b" }, { "module": "@nacs/feature-telemetry" }]
}@nacs/feature-telemetry is a headless feature β it has no primary route, so it never appears in the sidebar. It contributes silently to the dashboard widget slot and lifecycle hook handlers. See Headless features below.
Example β production with pinned versions, a nav override, and a default route:
{
"$schema": "./client-config.schema.json",
"clientId": "client-prod-v1",
"defaultRoute": "@nacs/feature-a",
"features": [
{ "module": "@nacs/feature-dashboard", "version": "1.0.0", "overrides": { "title": "Home", "icon": "π " } },
{ "module": "@nacs/feature-a", "version": "1.0.0" }
]
}Routing metadata (path, export name, nav title, icon) is not declared in the config. It is discovered from the feature library's own package.json at build time β see Feature Contributions & Extensions below.
| Field | Required | Description |
|---|---|---|
clientId |
Yes | Unique identifier for this client configuration. |
features |
Yes | Ordered list of feature modules to include. The first entry is the default redirect unless defaultRoute is set. |
defaultRoute |
No | Module name (must match a features[].module value) whose route is the default redirect. |
| Field | Required | Description |
|---|---|---|
module |
Yes | npm package name for the feature library. |
version |
No | Pins to a specific published version from the registry. Omit to use local workspace source. |
overrides |
No | Optionally override title or icon discovered from the library for this client only. |
To add a new client environment, create a new configs/<client-name>.json file referencing the schema and run:
npx nx run shell:build:production --client=<client-name>Each feature library is self-describing. Instead of declaring routing metadata in the client config, the library publishes it in its own package.json under the nacs-contributions field. The pre-build script reads this field after installing the package (for versioned features) or directly from the workspace source (for local features).
{
"nacs-contributions": {
"primary": {
"path": "feature-a",
"exportName": "featureARoutes",
"title": "Feature A",
"icon": "π"
},
"extensions": {
"admin": [
{
"path": "feature-a-settings",
"exportName": "featureAAdminRoutes",
"title": "Feature A Settings",
"icon": "βοΈ"
}
]
}
}
}| Field | Required | Description |
|---|---|---|
primary |
No | The top-level route this feature registers in the shell navigation. Omit to create a headless feature. |
primary.path |
β | Angular router path segment (lowercase, hyphens only) |
primary.exportName |
β | Named export from the library's public API containing the Routes array |
primary.title |
β | Label shown in the navigation sidebar |
primary.icon |
β | Emoji or icon identifier for the nav item |
extensions |
No | Map of extension points this feature contributes to |
extensions.admin |
β | Contributes a child route to the built-in Administration panel |
Sub-fields marked
βare required when their parent field is present.
A headless feature (also called a ghost feature) is a feature library that omits the primary field from its nacs-contributions. It has no route, no navigation entry, and no URL of its own. It exists purely to contribute to extension points declared by other libraries.
{
"nacs-contributions": {
"extensions": {
"dashboard-widget": [{ "exportName": "TelemetryWidget", "title": "Platform Telemetry", "icon": "π‘" }],
"lifecycle:user.logout": [{ "exportName": "telemetryLogoutHandler" }],
"lifecycle:session.expired": [{ "exportName": "telemetrySessionExpiredHandler" }]
}
}
}The key properties of headless features:
- No nav entry β the sidebar is unchanged whether the feature is present or absent.
- Silent removal β dropping the feature from a client config removes all its contributions (widgets, handlers, etc.) with zero code changes.
- Full governance β peer dependency checks still apply; headless features are not exempt.
Note: A client config where all features are headless is a build error. At least one feature must declare a primary route to generate valid navigation.
Extension points are declared by consumer libs (e.g. core-admin, feature-dashboard, shell-nav, shell-lifecycle) via nacs-contributions.extensionPoints in their package.json. Each extension point has an itemType that controls how prepare-build generates code for that slot.
itemType |
Import emitted | DI provider pattern | Status | Example use cases |
|---|---|---|---|---|
route |
None β lazy loadChildren lambda |
None (consumed directly by router) | β Implemented | Admin panel tabs, user settings sections, onboarding wizard steps |
component |
Static class import | { provide: TOKEN, useValue: [...] } |
β Implemented | Dashboard widgets, sidebar nav badges, contextual help panels |
lazy-component |
Dynamic import() factory (no static import) |
{ provide: TOKEN, useValue: [{ load: () => import(...) }] } |
β Implemented | Heavy chart/editor widgets, map views β split into their own chunk even when the feature is in config |
lifecycle-hook |
Static function import | { provide: TOKEN, useValue: fn, multi: true } per contributor |
β Implemented | Logout cleanup, session expiry handling, tenant switch teardown |
multi-provider |
Static class import | { provide: TOKEN, useClass: X, multi: true } per contributor |
π² Planned | Global search providers, telemetry adapters, notification handlers |
value |
None β data inlined from package.json |
{ provide: TOKEN, useValue: [...] } |
β Implemented | Help topic registrations, permission/capability declarations, i18n namespace registrations, feature flags |
initializer |
Static function import | { provide: APP_INITIALIZER, useFactory: fn, deps: [...], multi: true } |
π² Planned | Pre-fetch feature config, register service workers, warm caches before app renders |
Key distinction between component and multi-provider: component contributions are plain objects in an array β the shell renders them but they cannot inject other services themselves. multi-provider contributions are DI-resolved class instances, enabling each contributor to declare its own deps and participate fully in Angular's dependency injection graph.
Key distinction between lifecycle-hook and multi-provider: lifecycle-hook handlers are stateless functions β they cannot inject services themselves. They are called imperatively by a dispatcher at a named moment in application time (e.g. logout, session expiry). Use lifecycle-hook for fire-and-forget cleanup; use multi-provider (see above) when the handler needs its own dependencies.
When the pre-build script runs, it:
- Resolves each feature's
package.json(fromnode_modulesfor versioned installs, from the workspace source for local) - Enforces peer dependency governance on every feature, including headless ones
- Reads
nacs-contributions.primary(if present) to generate the top-levelgeneratedRoutesarray β features withoutprimaryare skipped for route generation - Reads
nacs-contributions.extensions.*from all features (including headless ones) to generate extension point arrays (e.g.extDashboardWidget) andmulti: trueproviders (e.g. lifecycle-hook handlers)
All generated code is written into a single file: apps/shell/src/app/app.composition.generated.ts.
The shell's app.routes.ts imports route arrays and passes them to factory functions at composition time. The shell's app.config.ts spreads generatedProviders β which includes both component/lazy-component token bindings and lifecycle-hook multi: true handler registrations β into the application providers.
Step 1 β Create an admin component and route export in the feature library:
libs/my-feature/src/lib/my-feature-admin/my-feature-admin.ts β standalone component
libs/my-feature/src/lib/my-feature-admin.routes.ts β routes export
my-feature-admin.routes.ts:
import { Route } from '@angular/router';
import { MyFeatureAdmin } from './my-feature-admin/my-feature-admin';
export const myFeatureAdminRoutes: Route[] = [{ path: '', component: MyFeatureAdmin }];Step 2 β Re-export from the library's public API (src/index.ts):
export * from './lib/my-feature-admin.routes';Step 3 β Declare the extension in the library's package.json:
{
"nacs-contributions": {
"primary": { ... },
"extensions": {
"admin": [
{
"path": "my-feature-settings",
"exportName": "myFeatureAdminRoutes",
"title": "My Feature Settings",
"icon": "βοΈ"
}
]
}
}
}Step 4 β Run prepare-build:
npx nx run shell:prepare-buildThe admin tab will appear automatically in the Administration panel for any client config that includes this feature. Features that declare no extensions.admin entry contribute nothing to the admin panel β omission is the opt-out.
Value contributions let features publish static, structured data declared entirely in package.json β no TypeScript code, no imports, and zero coupling between the contributing feature and the consuming library. The build-tools pipeline reads the JSON at build time, inlines it into the generated composition file as a typed array, and binds it to a DI token via generatedProviders. The consuming library injects the token to access all contributed data at runtime.
Step 1 β The consumer library declares the extension point and token:
libs/shell-help/package.json:
{
"name": "@nacs/shell-help",
"nacs-contributions": {
"extensionPoints": {
"help-topic": {
"itemType": "value",
"tokenExportName": "HELP_TOPICS"
}
}
}
}The consumer lib also defines and exports the token and the item interface:
// libs/shell-help/src/lib/help-topics.token.ts
export interface HelpTopic {
id: string;
title: string;
summary: string;
category: string;
icon?: string;
docUrl?: string;
}
export const HELP_TOPICS = new InjectionToken<HelpTopic[]>('HELP_TOPICS', {
factory: () => [],
});Step 2 β Feature libraries contribute data in their package.json:
No TypeScript changes are required in the contributing feature. Add the data array under nacs-contributions.extensions.<point-name>:
libs/feature-a/package.json:
{
"nacs-contributions": {
"primary": { ... },
"extensions": {
"help-topic": [
{
"id": "feature-a-overview",
"title": "Analytics Overview",
"summary": "Track record processing and sync status across all data pipelines.",
"category": "Analytics",
"icon": "π"
}
]
}
}
}Step 3 β Run prepare-build:
npx nx run shell:prepare-buildThe generated file will contain the inlined array bound to the token:
import { HELP_TOPICS } from '@nacs/shell-help';
export const extHelpTopic = [
{ id: 'feature-a-overview', title: 'Analytics Overview', ... },
];
export const generatedProviders: Provider[] = [
// ...
{ provide: HELP_TOPICS, useValue: extHelpTopic },
];When a feature is removed from a client config, its contributed items disappear automatically on the next prepare-build β no code changes required anywhere.
Lifecycle hooks let features respond to application-level events (e.g. user.logout, session.expired) without importing anything from @nacs/shell-lifecycle. The feature exports a plain stateless function; prepare-build wires it into the dispatcher via multi: true DI providers at build time. If the feature is absent from a client config, its handler is fully tree-shaken.
See libs/shell-lifecycle/README.md for the full how-to, available events, and guidance on when to use a lifecycle hook versus a multi-provider contribution.
To ensure each client build is a hermetically sealed artifact, the pre-build engine enforces strict governance. When a feature is loaded from the registry by version, the script enforces that the feature's peerDependencies are satisfied by the shell's installed dependencies.
This prevents "Module Federation version mismatch" errors by catching framework or library version mismatches (e.g. an Angular 18 feature in an Angular 21 shell) before a bad build ever reaches your deployment pipeline.
The enforced peers are: @angular/core, @angular/common, @angular/router, rxjs.
If a violation is detected, the build fails immediately with a clear message:
β STRICT GOVERNANCE FAILURE: Version mismatch in @nacs/feature-a@1.0.0
The shell's core dependencies do not satisfy the feature's peer requirements:
- @angular/core: Feature requires '>=18 <19', Shell provides '~21.2.0'
Resolution: Update the client config to a newer feature version, or recompile the feature.
Local workspace features (no version field) are not checked β they share the workspace's node_modules directly and are always in sync.
This project treats each feature library as an independently versioned contract. This allows client configurations to pin a specific feature package version while the shell and other libraries continue to evolve separately.
Feature libraries utilize nx release for independent semver versioning. This process updates the library's package.json and creates matching git tags, ensuring that every published version is a stable, referenceable artifact.
Once feature packages are published to a private registry (such as Nexus or Artifactory), the shell resolves them based on the client configuration:
- Development: Features are resolved directly from local workspace source for instant hot-reloading.
- Production: Specific versions are resolved as versioned npm packages via npm aliasing, ensuring that Client A physically cannot download Client B's feature code.