Nx Monorepo
As Angular applications grow into multiple apps and shared libraries, the plain Angular CLI starts to strain: builds get slow, code-sharing turns ad hoc, and there is nothing stopping one feature from importing the internals of another. Nx is a build system and monorepo toolkit that solves exactly these problems. It understands the dependency graph between your projects, caches the results of every task, and ships first-class Angular generators so a workspace of dozens of apps and libraries stays fast, consistent, and enforceably modular.
Creating an Nx Angular workspace
You scaffold a new workspace with create-nx-workspace. Choosing the angular preset wires up an Angular application, the build executors, and the Nx plugins in one step.
npx create-nx-workspace@latest acme --preset=angular-monorepo \
--appName=storefront --style=scss --standalone --e2eTestRunner=playwright
This produces a workspace with apps/storefront and a libs/ directory ready for shared code. To add more projects later, use the Angular plugin generators:
# A second deployable app
npx nx g @nx/angular:app apps/admin --standalone
# A buildable, publishable UI library
npx nx g @nx/angular:library libs/ui-button --buildable --standalone
Nx prefers small, focused libraries over a few large ones. More granular libraries mean a finer dependency graph, which means more cache hits and faster, more parallel builds.
The project graph
Nx derives a project graph by statically analysing your import statements plus configuration files. Every app and library is a node; every import is an edge. This graph drives caching, affected detection, and module-boundary enforcement. Explore it interactively:
npx nx graph
Because the graph is computed from real code, it never drifts out of sync with what your app actually imports — there is no manifest to maintain by hand.
Computation caching
Nx caches the output of any task it runs. The cache key is a hash of the project’s source, its dependencies’ source, the command, and relevant environment. If nothing that affects the result has changed, Nx replays the previous output instantly instead of re-running the task.
npx nx build storefront
# edit nothing, run again:
npx nx build storefront
Output:
> nx run storefront:build [existing outputs match the cache, left as is]
Successfully ran target build for project storefront (15ms)
Nx read the output from the cache instead of running the command for 1 out of 1 tasks.
The same caching applies to test, lint, and e2e. Connecting the workspace to Nx Cloud shares this cache across your whole team and CI, so a build another developer already ran is free for you.
Running only what changed
In a large monorepo you rarely need to rebuild everything. The affected commands use the project graph to test or build only projects touched by a change set, compared against a base branch.
# Lint and test only projects affected since main
npx nx affected -t lint test --base=main --head=HEAD
# Build everything, in parallel, in graph order
npx nx run-many -t build --all --parallel=3
| Command | What it does |
|---|---|
nx <target> <project> | Run one target for one project |
nx run-many -t <target> | Run a target across many projects |
nx affected -t <target> | Run a target only on graph-affected projects |
nx graph | Visualise the dependency graph |
nx g <generator> | Scaffold code with a generator |
Enforcing module boundaries
The most valuable architectural feature is the @nx/enforce-module-boundaries ESLint rule. You tag each project, then declare which tags may depend on which. The linter fails the build when a rule is violated, so your intended architecture is enforced mechanically rather than by convention.
Tag a project in its project.json:
{
"name": "ui-button",
"tags": ["scope:shared", "type:ui"]
}
Then define the allowed dependencies in the root ESLint config:
// eslint.config.mjs (flat config)
export default [
{
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
depConstraints: [
{ sourceTag: 'type:feature', onlyDependOnLibsWithTags: ['type:ui', 'type:data'] },
{ sourceTag: 'type:ui', onlyDependOnLibsWithTags: ['type:ui'] },
{ sourceTag: 'scope:admin', onlyDependOnLibsWithTags: ['scope:admin', 'scope:shared'] },
],
},
],
},
},
];
Now a type:ui library that tries to import a type:data service is a lint error. Combined with import aliases (@acme/ui-button), this keeps standalone components and signal-based stores cleanly layered.
Generators
Generators are code scaffolds that respect your conventions. They create standalone components, functional guards, and stores in the right place with the right boilerplate.
npx nx g @nx/angular:component libs/ui-button/src/lib/button --standalone
npx nx g @schematics/angular:guard libs/auth/src/lib/auth --functional
The generated component uses modern Angular by default:
import { Component, input } from '@angular/core';
@Component({
selector: 'acme-button',
standalone: true,
template: `
@if (disabled()) {
<button disabled>{{ label() }}</button>
} @else {
<button>{{ label() }}</button>
}
`,
})
export class ButtonComponent {
label = input.required<string>();
disabled = input(false);
}
Best Practices
- Split code into many small, single-purpose libraries rather than a handful of large ones — granularity is what makes caching and
affectedeffective. - Tag every project with a
scope:and atype:and enforce them with@nx/enforce-module-boundariesso architecture cannot erode silently. - Wire
nx affectedinto CI against your default branch to cut pipeline time to only the work that actually changed. - Enable Nx Cloud to share the computation cache across developers and CI, turning repeated builds into instant cache replays.
- Always scaffold with generators so naming, file layout, and standalone/signal conventions stay uniform across the workspace.
- Run
nx graphduring reviews to confirm new dependencies match your intended architecture before they merge.