Lab 26: NX β Monorepo Architecture
π Resourcesβ
π Starter Codeβ
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.
π Instructionsβ
Step 1: Create the NX Workspaceβ
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
Step 2: Serve the Appβ
cd my-workspace
nx serve shop
Open http://localhost:4200 β the default Angular app is running.
Step 3: Generate a Second Applicationβ
nx g @nx/angular:app admin
Now you have two independent apps sharing the same repository:
apps/
βββ shop/
βββ admin/
Run each independently:
nx serve shop # http://localhost:4200
nx serve admin # http://localhost:4201
Step 4: Generate a Shared UI Libraryβ
nx g @nx/angular:lib ui --directory=libs/ui
Note (Nx v22+): Pass
uias the positional name and--directory=libs/uias the full output path. Avoid combining--name=uiand--directory=libs/uitogether β Nx treats--directoryas the complete path, so the two flags can produce a nestedlibs/ui/ui/instead of the intendedlibs/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 choseacme, the alias will be@acme/ui. Always readtsconfig.base.json β compilerOptions.pathsto 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.
Step 5: Create a Shared Componentβ
Generate a button component inside the library:
nx g @nx/angular:component libs/ui/src/lib/button/button --export
Note (Nx v22+): The
--projectflag 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';
Step 6: Use the Library in the Shop Appβ
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.
Step 7: Visualize the Dependency Graphβ
nx graph
The browser opens an interactive graph. Verify:
shopdepends onuiadminhas no dependencies yet
Step 8: Run Affected Commandsβ
Make a change to ButtonComponent, then:
# 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.