π Angular 14 β 20: The Modernization Guide That Actually Feels Good to Read
A deep technical guide to Angular changes from version 14 to 20, including standalone components, signals, control flow, defer, SSR, routing, DI, forms, build tooling, and practical migration schematics with before/after code examples.

Short version:
Angular 14 felt powerful but ceremonial.
Angular 20 feels powerful, explicit, and modern.
This guide is for developers who want to understand the real evolution from Angular 14 to Angular 20 without drowning in a boring version-by-version essay.
Youβll get:
- π§ the mental model shift
- β‘ the changes that actually matter
- π§° the official migration schematics
- π§ͺ small before/after code demos
- β οΈ pitfalls that bite teams
π Table of Contents
- β‘ Angular 14 vs 20 at a Glance
- π§ The Big Mental Model Shift
- π What Changed, Version by Version
- π₯ The 10 Changes That Matter Most
- π§° Migration Schematics: Automate the Boring Parts
- π§ͺ Mini Demo: Old Angular vs Modern Angular
- β οΈ Pitfalls That Bite Teams
- π§Ύ Final Summary
β‘ Angular 14 vs 20 at a Glance
| Area | Angular 14 | Angular 20 | Why It Matters |
|---|---|---|---|
| Architecture | NgModule-first | Standalone-first | Less boilerplate, clearer dependencies |
| Reactivity | RxJS-heavy | Signals-first, RxJS where useful | Simpler state, finer-grained updates |
| Templates | *ngIf, *ngFor |
@if, @for, @switch, @let |
Cleaner, more readable UI logic |
| Lazy Loading | loadChildren modules |
loadComponent + @defer |
Smaller bundles, smarter loading |
| Routing | RouterModule.forRoot |
provideRouter |
Functional, composable, modern |
| DI | Constructor injection | inject() |
Works everywhere, less noise |
| Inputs | @Input() |
input() |
Reactive, typed, signal-friendly |
| Outputs | @Output() |
output() |
Modern event API |
| Queries | @ViewChild() / QueryList |
viewChild() / viewChildren() |
Reactive DOM access |
| Build | Webpack-centric | esbuild/Vite-based app builder | Faster dev loop |
| SSR | Possible, but rougher | Hydration, event replay, view transitions | Better real-world SSR |
π‘ Theme: Angular didnβt become a different framework.
It removed ceremony and made the modern path the default path.
π§ The Big Mental Model Shift
Angular 14 thinking
You often started with a module:
@NgModule({
declarations: [UserComponent],
imports: [CommonModule, UserRoutingModule],
})
export class UserModule {}
Then you thought about:
- declarations
- imports
- exports
- routing modules
- feature modules
- lazy module loading
Angular 20 thinking
You often start with the component itself:
@Component({
selector: "app-user",
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet />`,
})
export class UserComponent {}
And routes can load components directly:
export const routes: Routes = [
{
path: "user",
loadComponent: () =>
import("./user/user.component").then((m) => m.UserComponent),
},
];
β Result: fewer files, fewer indirection layers, clearer ownership.
π What Changed, Version by Version
Instead of a long essay, hereβs the evolution as a compact map.
| Version | Headline | Why It Mattered |
|---|---|---|
| 14 | Classic Angular | NgModules, RxJS, structural directives |
| 15 | Standalone becomes real | standalone: true, bootstrapApplication, provideRouter |
| 16 | Signals appear | New reactivity model begins |
| 17 | Templates modernize | @if, @for, @switch, @defer |
| 18 | Signals mature | Better state, effects, SSR-safe APIs, zoneless direction |
| 19 | DX polish | @let, model(), resource APIs, view transitions |
| 20 | Modern defaults | Standalone + signals + control flow + defer feel normal |
π§ If you only remember one sentence:
Angular moved from module ceremony to explicit reactive components.
π₯ The 10 Changes That Matter Most
1. Standalone Components Are the Default Mental Model
Old:
@NgModule({
declarations: [ProfileComponent],
imports: [CommonModule],
})
export class ProfileModule {}
New:
@Component({
selector: "app-profile",
standalone: true,
template: `<p>{{ name() }}</p>`,
})
export class ProfileComponent {
name = signal("Anik");
}
Why it matters:
- no mandatory NgModule
- dependencies are explicit
- lazy loading becomes simpler
- codebases feel less enterprise-heavy
2. Signals Change How State Feels
Basic signal:
count = signal(0);
increment() {
this.count.update(value => value + 1);
}
Derived state:
double = computed(() => this.count() * 2);
Side effect:
effect(() => {
console.log("Count:", this.count());
});
Why it matters:
- simpler than
BehaviorSubjectfor local state - easier derived values
- better fit for future zoneless change detection
βοΈ Signals do not replace RxJS completely.
Use signals for state, RxJS for complex async streams.
3. Built-in Control Flow Makes Templates Beautiful
Old:
<div *ngIf="user">Hello {{ user.name }}</div>
<li *ngFor="let task of tasks; trackBy: trackById">{{ task.title }}</li>
New:
@if (user()) {
<div>Hello {{ user().name }}</div>
} @else {
<div>Please log in.</div>
} @for (task of tasks(); track task.id) {
<li>{{ task.title }}</li>
} @empty {
<p>No tasks found.</p>
}
Why it matters:
- easier to read
- less
ng-templatenoise - better template type checking
- works naturally with signals
4. @defer Brings Template-Level Lazy Loading
@defer (on viewport) {
<app-heavy-chart [data]="chartData()" />
} @placeholder {
<div class="skeleton"></div>
}
Common triggers:
@defer (on idle) { ... } @defer (on interaction) { ... } @defer (when
showAdvanced()) { ... }
Why it matters:
- smaller initial work
- better Time to Interactive
- perfect for charts, editors, comments, maps, heavy widgets
5. Routing Became Functional and Composable
Old:
@NgModule({
imports: [RouterModule.forRoot(routes)],
})
export class AppRoutingModule {}
New:
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding(), withViewTransitions()),
],
};
Why it matters:
- fits standalone apps
- easier to opt into features
- lazy loading components becomes natural
6. inject() Replaces Constructor Noise
Old:
constructor(
private http: HttpClient,
private router: Router
) {}
New:
private http = inject(HttpClient);
private router = inject(Router);
Why it matters:
- cleaner
- works in more contexts
- great for guards, resolvers, factories, field initialization
7. Signal Inputs Make Component APIs Reactive
Old:
@Input() title = 'Untitled';
@Input({ required: true }) id!: string;
New:
title = input("Untitled");
id = input.required<string>();
Template changes:
<h2>{{ title() }}</h2>
Why it matters:
- better integration with signals
- cleaner derived state
- more modern component contracts
8. Signal Outputs Replace EventEmitter Boilerplate
Old:
@Output() saved = new EventEmitter<Task>();
New:
saved = output<Task>();
Usage stays familiar:
this.saved.emit(task);
Why it matters:
- modern API surface
- better alignment with signals
- less legacy
EventEmittercoupling
9. Signal Queries Make DOM Access Reactive
Old:
@ViewChild('canvas') canvas?: ElementRef<HTMLCanvasElement>;
New:
canvas = viewChild<ElementRef<HTMLCanvasElement>>('canvas');
constructor() {
effect(() => {
const el = this.canvas()?.nativeElement;
if (el) this.draw(el);
});
}
Why it matters:
- no
QueryList.changessubscription dance - reactive updates
- cleaner SSR-aware patterns
10. SSR and Hydration Became Much More Practical
Modern apps can use:
provideClientHydration();
And browser-safe logic:
afterNextRender(() => {
// safe DOM / window / third-party library code
});
Why it matters:
- better first paint
- less flicker
- more realistic SSR for production apps
π§° Migration Schematics: Automate the Boring Parts
Angular gives you CLI schematics to modernize code faster.
β οΈ Golden rule: always run with
--dry-runfirst, review the diff, then apply.
ng generate @angular/core:standalone --path=src/app --dry-run
ng generate @angular/core:inject --path=src/app --dry-run
ng generate @angular/core:route-lazy-loading --path=src/app --dry-run
ng generate @angular/core:signal-input-migration --path=src/app --dry-run
ng generate @angular/core:signal-queries-migration --path=src/app --dry-run
ng generate @angular/core:output-migration --path=src/app --dry-run
Schematic Map
| Schematic | What It Does |
|---|---|
@angular/core:standalone |
Converts NgModule-based code to standalone |
@angular/core:inject |
Converts constructor DI to inject() |
@angular/core:route-lazy-loading |
Converts eager routes to loadComponent |
@angular/core:signal-input-migration |
Converts @Input() to input() |
@angular/core:signal-queries-migration |
Converts @ViewChild() etc. to signal queries |
@angular/core:output-migration |
Converts @Output() to output() |
1. @angular/core:standalone
Transformation
From:
@NgModule({
declarations: [HelloComponent],
imports: [CommonModule],
})
export class HelloModule {}
To:
@Component({
selector: "app-hello",
standalone: true,
template: `<p>Hello</p>`,
})
export class HelloComponent {}
What to expect
The schematic may:
- add
standalone: true - move imports into components
- remove declarations from modules
- delete empty modules
- update bootstrapping toward
bootstrapApplication()
Watch out for
- leftover NgModules with providers
- tests still importing old modules
BrowserModuleassumptions- application providers needing to move to
appConfig
β Best used early. It gives the biggest structural cleanup.
2. @angular/core:inject
Transformation
From:
constructor(
private http: HttpClient,
private router: Router
) {}
To:
private http = inject(HttpClient);
private router = inject(Router);
Token injection
From:
constructor(@Inject(API_URL) private apiUrl: string) {}
To:
private apiUrl = inject(API_URL);
Optional dependencies
From:
constructor(@Optional() @Inject(LOGGER) private logger?: Logger) {}
To:
private logger = inject(LOGGER, { optional: true });
Watch out for
- constructor logic that cannot be removed automatically
- tests manually constructing services
- optional dependencies becoming nullable
β Great for reducing boilerplate and modernizing DI style.
3. @angular/core:route-lazy-loading
Transformation
From:
import { DashboardComponent } from "./dashboard/dashboard.component";
export const routes: Routes = [
{ path: "dashboard", component: DashboardComponent },
];
To:
export const routes: Routes = [
{
path: "dashboard",
loadComponent: () =>
import("./dashboard/dashboard.component").then(
(m) => m.DashboardComponent
),
},
];
Why it matters
- smaller initial bundle
- route code loaded only when needed
- works beautifully with standalone components
Watch out for
- components imported eagerly elsewhere
- guards/resolvers accidentally importing lazy code
- dynamic route builders needing manual review
β One of the easiest performance wins after standalone migration.
4. @angular/core:signal-input-migration
Transformation
From:
@Input() title = 'Untitled';
@Input({ required: true }) id!: string;
To:
title = input("Untitled");
id = input.required<string>();
Template impact
From:
<h2>{{ title }}</h2>
To:
<h2>{{ title() }}</h2>
Aliases
From:
@Input('panelTitle') title = 'Untitled';
To:
title = input("Untitled", { alias: "panelTitle" });
Transforms
From:
@Input({ transform: booleanAttribute }) disabled = false;
To:
disabled = input(false, { transform: booleanAttribute });
Watch out for
- custom input setters
- direct writes like
this.title = 'x' - two-way binding patterns that should become
model()
β Excellent for modern component APIs, but review setters manually.
5. @angular/core:signal-queries-migration
Transformation
From:
@ViewChild('canvas') canvas?: ElementRef<HTMLCanvasElement>;
To:
canvas = viewChild<ElementRef<HTMLCanvasElement>>("canvas");
Children
From:
@ViewChildren(ItemComponent) items?: QueryList<ItemComponent>;
To:
items = viewChildren(ItemComponent);
React to changes
From:
ngAfterViewInit() {
this.items?.changes.subscribe(items => {
console.log(items.length);
});
}
To:
constructor() {
effect(() => {
console.log(this.items().length);
});
}
Watch out for
static: truequeries- DOM timing assumptions
- nullable access:
this.canvas()?.nativeElement - code expecting
QueryListinstead of arrays
β Very nice for reactive DOM logic, but test timing-sensitive components.
6. @angular/core:output-migration
Transformation
From:
@Output() saved = new EventEmitter<Task>();
To:
saved = output<Task>();
Emitting
Still familiar:
this.saved.emit(task);
Alias
From:
@Output('taskSaved') saved = new EventEmitter<Task>();
To:
saved = output<Task>({ alias: "taskSaved" });
Watch out for
- custom
EventEmittersubclasses - code checking
instanceof EventEmitter - input/output pairs that should become
model()
β Low-friction migration with a more modern API surface.
π§ͺ Mini Demo: Old Angular vs Modern Angular
Letβs look at one tiny component in two eras.
Feature
A task item that:
- receives a task
- emits a toggle event
- has a template ref input
- uses the router
Angular 14 Style
π Click to view the older version
import {
Component,
ElementRef,
EventEmitter,
Input,
Output,
ViewChild,
} from "@angular/core";
import { Router } from "@angular/router";
interface Task {
id: number;
title: string;
completed: boolean;
}
@Component({
selector: "app-task-item",
template: `
<li>
<input #titleInput [value]="task.title" />
<button (click)="toggle()">Toggle</button>
<button (click)="open()">Open</button>
</li>
`,
})
export class TaskItemComponent {
@Input() task!: Task;
@Output() toggled = new EventEmitter<Task>();
@ViewChild("titleInput") titleInput?: ElementRef<HTMLInputElement>;
constructor(private router: Router) {}
toggle() {
this.toggled.emit({
...this.task,
completed: !this.task.completed,
});
}
open() {
this.router.navigate(["/tasks", this.task.id]);
}
}
Angular 20 Style
import {
Component,
ElementRef,
inject,
input,
output,
viewChild,
} from "@angular/core";
import { Router } from "@angular/router";
interface Task {
id: number;
title: string;
completed: boolean;
}
@Component({
selector: "app-task-item",
standalone: true,
template: `
<li>
<input #titleInput [value]="task().title" />
<button (click)="toggle()">
@if (task().completed) {
Undo
} @else {
Complete
}
</button>
<button (click)="open()">Open</button>
</li>
`,
})
export class TaskItemComponent {
private router = inject(Router);
task = input.required<Task>();
toggled = output<Task>();
titleInput = viewChild<ElementRef<HTMLInputElement>>("titleInput");
toggle() {
this.toggled.emit({
...this.task(),
completed: !this.task().completed,
});
}
open() {
this.router.navigate(["/tasks", this.task().id]);
}
}
What changed?
| Old | New |
|---|---|
@Input() |
input.required() |
@Output() |
output() |
@ViewChild() |
viewChild() |
| constructor DI | inject() |
| ternary in template | @if / @else |
| NgModule-era component | standalone component |
β¨ This is the modern Angular shape: explicit, reactive, less ceremonial.
β οΈ Pitfalls That Bite Teams
| Pitfall | Why It Hurts | Better Approach |
|---|---|---|
| Rewriting everything at once | Huge risk, hard review | Upgrade, then modernize incrementally |
| Treating signals as RxJS replacement | Loses stream operators | Use signals for state, RxJS for streams |
| Mutating signal objects directly | Change may not be detected | Use .update() with new reference |
Forgetting track in @for |
Template invalid | Always provide stable track expression |
Using @defer everywhere |
Adds fragmentation overhead | Defer heavy/non-critical UI only |
| Enabling zoneless too early | Third-party DOM surprises | Move to signals first, audit carefully |
| Ignoring SSR browser APIs | Server crashes | Use afterNextRender() or platform-safe services |
Example of bad signal mutation:
user().name = "Anik";
Better:
user.update((current) => ({
...current,
name: "Anik",
}));
π§Ύ Final Summary
Angular 14 β 20 is not just a version jump.
It is a shift from:
@NgModule
constructor()
@Input()
@Output()
@ViewChild()
*ngIf
*ngFor
loadChildren
BehaviorSubject
to:
standalone: true
inject()
input()
output()
viewChild()
@if
@for
loadComponent
signal()
The biggest wins
- π§± less boilerplate
- β‘ clearer reactivity
- π§© better lazy loading
- π faster builds
- π stronger SSR story
- π§° official migration tooling
The best advice
Do not chase every new API on day one.
Upgrade safely, make new code modern, then gradually backfill.
Once your codebase starts using standalone components, signals, built-in control flow, and deferred views, going back to the old Angular style feels heavy.