---
name: angular-migration
description: Plan and execute Angular upgrades and modernisation — `ng update` one major at a time, NgModule to standalone, `*ngIf`/`*ngFor` to built-in control flow, constructor DI to `inject()`, decorators to signal inputs, outputs and queries, eager to lazy routes, `CommonModule` and `ngClass`/`ngStyle` removal, zone.js to zoneless, Karma to Vitest, `@angular/animations` to `animate.enter`/`animate.leave`, and Reactive Forms to Signal Forms. Use this whenever the user is on Angular 19 or older, asks how to upgrade Angular or "modernise" an Angular codebase, mentions `ng update`, a migration schematic, `ng generate @angular/core:…`, a breaking change after an upgrade, removing zone.js, or getting off Karma, `RouterTestingModule` or `TestBed.flushEffects()`. Also use it when a new-feature request turns out to be blocked by an unsupported Angular version.
metadata:
  technology: Angular
  type: migration
---

# Angular Migration
> **Targets:** upgrades into Angular 20 LTS – 22 · **Verified:** 2026-09 against https://angular.dev/reference/releases, https://angular.dev/reference/migrations, https://angular.dev/update-guide

A migration succeeds when every step is small, mechanical and independently shippable, and CI is green after each one. Angular ships schematics for almost all of this work — running the schematic and reviewing its diff beats a hand-rewrite on both speed and accuracy. The failure mode is the "big modernisation branch" that is six weeks old, conflicts with everything and can never be merged.

## 1. Decide first

| Question | Default | Change when |
|---|---|---|
| Upgrade or modernise first? | Upgrade to a supported major first; modernisation schematics assume current APIs | A v22 codebase already: skip to section 4 |
| How many majors at once | One at a time, `ng update` per major, commit and deploy between them | Never batch. Skipping a major skips its migrations and its deprecation warnings |
| Order of modernisation | Standalone → control flow → `inject()` → signal inputs/outputs/queries → lazy routes → cleanup | A single blocker (a library with no standalone support) can reorder, but standalone gates most of the rest |
| Branch strategy | One PR per schematic, straight to trunk, behind no flag | A schematic touching > 300 files → split with `--path` per feature folder |
| Test suite during the move | Keep Karma running until the app is on v21+ and standalone | The suite is already the bottleneck → move to Vitest first, it is independent of the rest |
| Hand edits after a schematic | Review the diff, fix what it flagged, commit as one change | Never hand-edit the same file the next schematic will rewrite |
| Below v20 | The upgrade plan is the deliverable, before any feature work | Only a hotfix is in scope |

Version status as of 2026-09: **v22 Active** (released 2026-06-03, LTS ends 2028-06), **v21 LTS** (ends 2027-06), **v20 LTS** (ends 2026-11-28). v19 and below get no fixes, including security fixes.

## 2. Upgrading majors

```bash
ng update                                  # shows what is available for this workspace
ng update @angular/core@21 @angular/cli@21 # one major, core and cli together
npm run test && npm run build              # green before the next major
```

- `ng update` runs that major's own migrations automatically. That is why skipping majors is expensive: the migrations do not run retroactively.
- Check https://angular.dev/update-guide for each hop with your exact from/to versions before starting; it lists the manual steps a schematic cannot do.
- Third-party packages (`@angular/material`, `@ngrx/*`, `@angular/cdk`) are updated in the same command as the core hop they support, not after.
- A library with no release for the target major is a decision, not a blocker to discover in week three: replace it, fork it, or stay one major back — but decide before the first `ng update`.
- Commit each major separately. A bisect across a combined 19→22 commit tells you nothing.

## 3. What changed per version

Per-version defaults — zoneless (v21), OnPush (v22), Vitest (v21), Signal Forms, `resource`/`httpResource` and Angular Aria (v22), incremental hydration by default (v22) — are tabulated in `version-notes.md` in `../angular-development/references/`. Read it before generating any code for a mixed-version repo.

The API changes most likely to break a build or mislead a generated fix: `afterRender` was renamed `afterEveryRender` in v20 with no alias; `TestBed.flushEffects()` is deprecated in favour of `TestBed.tick()`; `withIncrementalHydration()` is deprecated (incremental hydration is on by default in `provideClientHydration()`) and it enables event replay, so `withEventReplay()` should be dropped; `@angular/animations` is deprecated; `ChangeDetectionStrategy.Default` is deprecated in favour of `Eager`.

## 4. The modernisation schematics, in order

Run one, review the diff, run the tests, commit. Every command takes `--path <dir>` to scope it to one feature folder.

```bash
ng generate @angular/core:standalone                   # 3 modes, in this order:
#   1 convert components/directives/pipes to standalone
#   2 remove unnecessary NgModule classes
#   3 bootstrap with standalone APIs
ng generate @angular/core:control-flow                 # *ngIf/*ngFor/*ngSwitch → @if/@for/@switch
ng generate @angular/core:inject                       # constructor DI → inject()
ng generate @angular/core:signal-input-migration       # @Input → input()
ng generate @angular/core:output-migration             # @Output → output()
ng generate @angular/core:signal-queries-migration     # @ViewChild/@ContentChild → viewChild()/contentChild()
ng generate @angular/core:route-lazy-loading           # eager route components → loadComponent
ng generate @angular/core:common-to-standalone         # CommonModule → the directives/pipes actually used
ng generate @angular/core:ngclass-to-class             # ngClass → [class] bindings
ng generate @angular/core:ngstyle-to-style             # ngStyle → [style] bindings
ng generate @angular/core:cleanup-unused-imports       # drop imports the template no longer uses
ng generate @angular/core:self-closing-tag             # <x></x> → <x />
ng generate @angular/core:router-testing-module-migration  # RouterTestingModule → RouterModule + provideLocationMocks()
```

Note the singular in `self-closing-tag`, and that the `inject()` schematic is `:inject` — `:inject-migration` does not exist.

`:inject` takes `--migrate-abstract-classes` (off by default, because Angular does not validate abstract-class parameters), `--backwards-compatible-constructors` and `--non-null-assertion`. `:ngstyle-to-style` has `--best-effort-mode` for object-reference bindings it would otherwise skip as unsafe.

**Review every diff.** The schematics are conservative and correct on what they touch, but they leave the judgement calls: a converted component may now import more than it needs, and `:control-flow` cannot know that an `*ngFor` should have gained an `@empty` branch.

## 5. The moves with no schematic

Four migrations are manual. Each is independently shippable, and each has a decision rather than a mechanical rule — details and step-by-step recipes in `references/manual-migrations.md`.

- **zone.js → zoneless.** Add `provideZonelessChangeDetection()` on v20, or confirm nothing provides `provideZoneChangeDetection()` on v21+, remove `zone.js` from `polyfills`, then fix what stops updating: anything relying on zone patching now needs a signal write, a template/host listener, `markForCheck()` or `ComponentRef.setInput`. `NgZone.runOutsideAngular` calls become no-ops and should be deleted.
- **Karma → Vitest.** `npm install --save-dev vitest jsdom`, set the `test` builder to `@angular/build:unit-test`, then `ng g @schematics/angular:refactor-jasmine-vitest` to convert spec syntax. It does not install dependencies, change `angular.json` or delete `karma.conf.js` — you do those. `fakeAsync`/`tick` stop working once `zone.js` is gone; replace with fake timers or `await fixture.whenStable()`.
- **`@angular/animations` → CSS.** `animate.enter="<class>"` and `animate.leave="<class>"` in templates, ordinary class bindings for state transitions, CSS transitions and keyframes for the animation itself.
- **Reactive Forms → Signal Forms.** Only on v22+, only for forms you are already changing, and never half a form at a time. Typed Reactive Forms are not deprecated; a working reactive form is not technical debt.

## 6. Deliverable format

```
## Current state — Angular version, support status, NgModule/standalone split, test runner, zone.js, blockers
## Sequence — numbered steps, each one PR, each with its command and how it is verified
## Per-step risk — what could break and what test covers it
## Manual work — what no schematic does, with the estimate
## Not doing — what is deliberately left alone, and why
```

## Checklist

- [ ] Current and target versions stated, with support status
- [ ] Upgrade hops are one major at a time, each with its own commit and green CI
- [ ] Every third-party Angular dependency checked for a release supporting the target major
- [ ] The update guide consulted for each hop's manual steps
- [ ] Schematic commands are exact (`:inject`, `:self-closing-tag`, `:signal-input-migration`) and scoped with `--path` where the diff would be huge
- [ ] Each schematic is its own PR with a reviewed diff, not a batch
- [ ] Removed APIs handled: `afterRender`, `TestBed.flushEffects()`, `withIncrementalHydration()`, `withEventReplay()`, `@angular/animations`
- [ ] Zoneless step verified by clicking through, not just by a green unit suite
- [ ] Nothing rewritten that works and is not deprecated (typed Reactive Forms, `@Injectable`, existing Jest suites)

## Anti-patterns

- **One long-lived "modernisation" branch** → one PR per schematic onto trunk; a six-week branch merges never.
- **Skipping majors ("19 straight to 22")** → each `ng update` runs that major's migrations; skipping means doing them by hand, later, without the warnings.
- **Hand-rewriting what a schematic does** → slower and less accurate; run it, review it, then hand-fix only what it flagged.
- **`ng generate @angular/core:inject-migration`** → the schematic is `@angular/core:inject`.
- **Converting Reactive Forms to Signal Forms across the app** → Reactive Forms are supported; convert only forms you are already rewriting, on v22+.
- **Removing `zone.js` while the suite still uses `fakeAsync`** → move the tests first, or the suite fails for a reason unrelated to the app.
- **Keeping `withEventReplay()` and `withIncrementalHydration()` after v22** → both are redundant; incremental hydration is the default and enables event replay itself.
- **Mixing `.component.ts` and suffix-less names as the rename proceeds** → do the rename as one mechanical PR, or not at all (see `angular-architecture`).

## Go deeper

- `references/manual-migrations.md` — zoneless, Karma → Vitest, animations → CSS, Reactive → Signal Forms, `RouterTestingModule`, step by step
- `references/upgrade-playbook.md` — a worked 17 → 22 sequence with per-hop commands, checkpoints and rollback
- `version-notes.md` in `../angular-development/references/` — what is default in v20, v21 and v22
- Siblings: `angular-architecture` (target structure), `angular-development` (the modern idioms), `angular-testing` (the new suite), `angular-ssr`
