Skip to content

Lab 26: NX — Monorepo Architecture

No starter — you will create a fresh NX workspace from scratch.

In this lab you will set up an NX monorepo, generate two Angular applications, create a shared component library, and use the path alias to consume it. You will also explore the dependency graph and the affected command.

Terminal window
npx create-nx-workspace@latest my-workspace

When prompted:

  • Stack: Angular
  • Type: Integrated Monorepo
  • Application name: shop
  • Stylesheet format: CSS (or SCSS)
  • Enable distributed caching (Nx Cloud): No
my-workspace/
├── apps/
│ └── shop/
├── libs/
├── nx.json
├── tsconfig.base.json
└── package.json
Terminal window
cd my-workspace
nx serve shop

Open http://localhost:4200 — the default Angular app is running.

Terminal window
nx g @nx/angular:app admin

Now you have two independent apps sharing the same repository:

apps/
├── shop/
└── admin/

Run each independently:

Terminal window
nx serve shop # http://localhost:4200
nx serve admin # http://localhost:4201
Terminal window
nx g @nx/angular:lib ui --directory=libs/ui

Note (Nx v22+): Pass ui as the positional name and --directory=libs/ui as the full output path. Avoid combining --name=ui and --directory=libs/ui together — Nx treats --directory as the complete path, so the two flags can produce a nested libs/ui/ui/ instead of the intended libs/ui/.

Why libs/ and not src/?

A standard Angular project keeps everything inside src/. In an Nx Integrated Monorepo the convention is to place shareable code at the root libs/ folder so multiple apps (shop, admin, …) can all import from it without circular-path hacks. Each library is an independent TypeScript project with its own project.json.

libs/ui vs libs/shop/shared-ui

Path Scope Use when…
libs/ui/ Workspace-wide Generic UI primitives (buttons, icons, typography) used by any app
libs/shop/shared-ui/ Shop scope only Components specific to the shop domain that should not bleed into other scopes

Use libs/ui for building blocks that could belong to a design system. Use libs/shop/shared-ui for cart widgets, product cards, or anything shop-specific.

Check the path alias Nx generated

After the generator runs, open tsconfig.base.json and look for the new entry under paths:

{
"compilerOptions": {
"paths": {
"@my-workspace/ui": ["libs/ui/src/index.ts"]
}
}
}

This is how TypeScript resolves @my-workspace/ui to the actual source files in libs/ui/src/index.ts. No webpack alias or custom resolver is needed — it is standard TypeScript path mapping.

⚠️ The prefix depends on the org name entered during create-nx-workspace. If you chose acme, the alias will be @acme/ui. Always read tsconfig.base.json → compilerOptions.paths to find the exact alias for your workspace before using it in imports.

Tags and module boundaries

Nx enforces import rules via @nx/enforce-module-boundaries in eslint.config.mjs. Libraries must carry tags so the rule knows which scopes may import them.

Open libs/ui/project.json and verify (or add) the tags field:

{
"tags": ["scope:shared", "type:ui"]
}

Without scope:shared, ESLint will block any app from importing this library once depConstraints are configured. You can inspect the boundary rules in eslint.config.mjs:

// eslint.config.mjs (excerpt)
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{ "sourceTag": "scope:shop", "onlyDependOnLibsWithTags": ["scope:shop", "scope:shared"] },
{ "sourceTag": "scope:admin", "onlyDependOnLibsWithTags": ["scope:admin", "scope:shared"] }
]
}
]

This means shop may only import libs tagged scope:shop or scope:shared. Tag libs/ui with scope:shared so both apps can use it freely.

Generate a button component inside the library:

Terminal window
nx g @nx/angular:component libs/ui/src/lib/button/button --export

Note (Nx v22+): The --project flag has been removed from the component generator. Pass the full path to where the component should be created instead.

Edit libs/ui/src/lib/button/button.component.ts:

import { Component, input } from '@angular/core';
@Component({
selector: 'lib-button',
standalone: true,
template: `
<button class="btn" [class]="variant()">
<ng-content />
</button>
`,
styles: [`
.btn { padding: 8px 16px; border: none; cursor: pointer; border-radius: 4px; }
.primary { background: #007bff; color: white; }
.secondary { background: #6c757d; color: white; }
`]
})
export class ButtonComponent {
variant = input<'primary' | 'secondary'>('primary');
}

Make sure it is exported from libs/ui/src/index.ts:

export * from './lib/button/button.component';

In apps/shop/src/app/app.component.ts, import using the alias you found in tsconfig.base.json:

import { ButtonComponent } from '@my-workspace/ui'; // replace prefix if yours differs
@Component({
selector: 'app-root',
standalone: true,
imports: [ButtonComponent],
template: `
<h1>Shop</h1>
<lib-button variant="primary">Add to cart</lib-button>
<lib-button variant="secondary">View details</lib-button>
`
})
export class AppComponent {}

nx serve shop — the shared button renders inside the shop app.

Terminal window
nx graph

The browser opens an interactive graph. Verify:

  • shop depends on ui
  • admin has no dependencies yet

Make a change to ButtonComponent, then:

Terminal window
# Only build what changed + what depends on it
nx affected --target=build
# Only test what changed + what depends on it
nx affected --target=test

NX determines that changing ui affects shop (which depends on it) — so both are rebuilt. admin is untouched.

This is the key benefit in CI: you only pay for what changed.