A realistic sequence for a medium app (≈120 components, NgModules, Karma, @angular/animations, Material). Adapt the version hops; the shape holds for any starting point. Verified 2026-09 against https://angular.dev/reference/releases and https://angular.dev/update-guide.
Phase 0 — before touching anything (half a day)
- Record the baseline:
npm test,ng build, and the number of failing/skipped specs. An upgrade onto a red suite cannot be evaluated. - Inventory dependencies:
npm ls --depth=0. For every package with@angularas a peer dependency, find the version that supports v22. Anything with no such release is a decision — replace, fork, or stop one major short — and it is cheaper now than in week three. - Read https://angular.dev/update-guide for 17 → 18, 18 → 19, 19 → 20, 20 → 21 and 21 → 22 with "Advanced" turned on. Note the manual steps; there are usually two or three per hop.
- Agree the rule with the team: trunk stays deployable after every step. No long-lived branch.
Phase 1 — the version hops (one PR each)
ng update @angular/core@18 @angular/cli@18 && npm test && ng build
ng update @angular/material@18 # same hop, not later
# commit, merge, deploy
ng update @angular/core@19 @angular/cli@19 && npm test && ng build
ng update @angular/core@20 @angular/cli@20 && npm test && ng build
ng update @angular/core@21 @angular/cli@21 && npm test && ng build
ng update @angular/core@22 @angular/cli@22 && npm test && ng build
Checkpoints worth expecting on the way:
| Hop | What usually bites |
|---|---|
| → 18 | Material 3 theming API; @angular/material theme files need rework |
| → 19 | standalone: true becomes the default — remove it from new code, and expect lint noise |
| → 20 | afterRender renamed to afterEveryRender; the AfterRenderPhase enum removed; CLI starts generating suffix-less file names |
| → 21 | Zoneless becomes the default for new apps and Vitest the default runner — existing config is untouched, but the defaults you read about change |
| → 22 | @angular/animations deprecation surfaces; withIncrementalHydration()/withEventReplay() become redundant; ChangeDetectionStrategy.Default deprecated in favour of Eager |
Do not start modernising between hops. Mixing an ng update migration with a hand rewrite in one PR makes both unreviewable.
Phase 2 — standalone (three PRs)
ng generate @angular/core:standalone # choose mode 1: convert to standalone
npm test && ng build # commit
ng generate @angular/core:standalone # mode 2: remove unnecessary NgModules
npm test && ng build # commit
ng generate @angular/core:standalone # mode 3: bootstrap with standalone APIs
npm test && ng build # commit
Run the modes in order and verify between each. Mode 2 will leave NgModules that genuinely still provide something — that is correct, not a failure; convert those by hand later or leave them.
The one hand step after mode 3: main.ts now calls bootstrapApplication(App, appConfig), and every forRoot() provider from the old AppModule has to be a provide*() function in app.config.ts. importProvidersFrom(SomeModule) is the escape hatch for a library that still only ships an NgModule.
Phase 3 — template and DI modernisation (one PR each)
ng generate @angular/core:control-flow
ng generate @angular/core:inject
ng generate @angular/core:signal-input-migration
ng generate @angular/core:output-migration
ng generate @angular/core:signal-queries-migration
ng generate @angular/core:common-to-standalone
ng generate @angular/core:ngclass-to-class
ng generate @angular/core:ngstyle-to-style
ng generate @angular/core:cleanup-unused-imports
ng generate @angular/core:self-closing-tag
ng generate @angular/core:route-lazy-loading
For a repo where one of these touches more than ~300 files, split by feature:
ng generate @angular/core:control-flow --path src/app/features/orders
Review each diff. The two things schematics cannot decide for you: whether a converted @for should have gained an @empty branch, and whether a component's remaining imports are the minimal set.
Phase 4 — test suite (one PR, or two)
Karma → Vitest as described in manual-migrations.md. Do this before phase 5: removing zone.js breaks every fakeAsync test, and you want that breakage on a suite you have already converted.
Phase 5 — zoneless (one PR, plus a click-through)
Remove zone.js from polyfills, confirm no provideZoneChangeDetection() remains, then click through every feature by hand. This is the one step a green unit suite does not validate, because the specs never exercised zone patching in the first place.
Budget a day for the fallout in a medium app, concentrated in: third-party widget callbacks, addEventListener outside Angular, and anything that used to update "somehow" after a promise.
Phase 6 — the optional rest
@angular/animations→animate.enter/animate.leave(one PR per animated feature; there is no rush, but the package will not be there forever)..component.ts→ suffix-less names, if the team wants it: one mechanical PR, no behaviour change, landed on a quiet day.- New forms in Signal Forms. Existing Reactive Forms stay.
Estimating
For a medium app with a green suite, on a team that keeps trunk deployable:
| Phase | Typical |
|---|---|
| 0 inventory and reading | 0.5 day |
| 1 five version hops | 1–2 days, mostly waiting on CI and fixing Material theming |
| 2 standalone | 1 day |
| 3 template/DI schematics | 1–2 days, almost all of it reviewing diffs |
| 4 Karma → Vitest | 1–3 days, depending on how much fakeAsync is in the suite |
| 5 zoneless | 1 day plus the click-through |
| 6 optional | ongoing |
The number that blows up an estimate is never the schematics. It is a dependency with no v22 release, or a suite that was already red. Both are visible in phase 0 — which is why phase 0 is not optional.
Rollback
Every phase is one merged PR, so rollback is git revert of that PR plus a redeploy. That property is the entire reason for the one-PR-per-step rule; it is worth more than the time it appears to cost.