Angular 22 is a cleanup release — it removes deprecated APIs, changes two defaults, and enforces stricter templates. Most Angular 21 apps survive the upgrade with minimal code changes if you follow the steps below in order. Do not skip the environment step.
Before You Start — Branch First
Always upgrade in a separate Git branch. Run your tests after every step, not just at the end.
git checkout -b upgrade/angular-22
Step 1 — Upgrade Node.js and TypeScript
This is the most skipped step and the most common cause of upgrade failures. Angular 22 requires:
- Node.js 22+ (Angular 21 ran on Node 20)
- TypeScript 6+ (Angular 21 used TypeScript 5)
Check your current versions:
node -v # needs to be v22.x.x or higher
tsc -v # needs to be 6.x or higher
Upgrade Node (using nvm):
nvm install 22
nvm use 22
Upgrade TypeScript:
npm install typescript@latest
Verify before moving on:
node -v && tsc -v
Do not proceed to Step 2 until both are at the required versions.
Step 2 — Run the Angular CLI Migration
With Node and TypeScript in order, run the official Angular upgrade command:
ng update @angular/core@22 @angular/cli@22
The CLI migration handles several things automatically:
- Adds
ChangeDetectionStrategy.Eagerto components that relied on the oldDefaultstrategy, so they keep working exactly as before - Updates any internally-known API replacements it can detect
- Flags things it cannot fix automatically in the output log — read the output carefully
After the command finishes, commit the migration changes before touching anything else:
git add -A
git commit -m "chore: angular 22 CLI migration"
Step 3 — Remove ComponentFactoryResolver (Breaking)
Angular 22 fully removes ComponentFactoryResolver and ComponentFactory. These were deprecated since Angular 13 and are now gone. If your app uses dynamic components for modals, dialogs, widgets, or panels, this is the most likely thing to break.
Before (Angular 21 — now broken):
import { Component, ComponentFactoryResolver, ViewContainerRef } from '@angular/core'
import { DialogComponent } from './dialog.component'
@Component({ selector: 'app-host', template: `<ng-container #container></ng-container>` })
export class HostComponent {
constructor(
private resolver: ComponentFactoryResolver,
private vcr: ViewContainerRef
) {}
openDialog() {
const factory = this.resolver.resolveComponentFactory(DialogComponent)
this.vcr.createComponent(factory)
}
}
After (Angular 22):
import { Component, ViewContainerRef } from '@angular/core'
import { DialogComponent } from './dialog.component'
@Component({ selector: 'app-host', template: `<ng-container #container></ng-container>` })
export class HostComponent {
constructor(private vcr: ViewContainerRef) {}
openDialog() {
this.vcr.createComponent(DialogComponent) // no factory needed
}
}
Search your entire codebase for these and remove them:
grep -rn "ComponentFactoryResolver\|ComponentFactory\|resolveComponentFactory" src/
Every result is a required fix. The pattern has been the recommended approach since Angular 13 — Angular 22 just enforces it.
Step 4 — Replace provideRoutes() with provideRouter() (Breaking)
provideRoutes() is removed in Angular 22. If your app used it to provide routes (common in lazy-loaded feature modules), replace it with provideRouter().
Before:
// Angular 21 — provideRoutes is removed in Angular 22
import { provideRoutes } from '@angular/router'
@NgModule({
providers: [provideRoutes(featureRoutes)]
})
export class FeatureModule {}
After:
// Angular 22
import { provideRouter } from '@angular/router'
@NgModule({
providers: [provideRouter(featureRoutes)]
})
export class FeatureModule {}
For standalone apps, this pattern is already correct. The issue is primarily in apps using NgModules with lazy-loaded routing. Search for all usages:
grep -rn "provideRoutes" src/
Step 5 — Fix Route Guard Signatures (CanMatchFn)
CanMatchFn guards now receive a third argument — the current router snapshot. If your guards had a two-argument signature, TypeScript will now complain.
Before (Angular 21):
import { CanMatchFn } from '@angular/router'
export const authGuard: CanMatchFn = (route, segments) => {
const auth = inject(AuthService)
return auth.isLoggedIn()
}
After (Angular 22):
import { CanMatchFn, RouterStateSnapshot } from '@angular/router'
export const authGuard: CanMatchFn = (route, segments, currentSnapshot) => {
const auth = inject(AuthService)
const router = inject(Router)
if (auth.isLoggedIn()) return true
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: currentSnapshot?.url }
})
}
The currentSnapshot parameter gives you access to the current URL at the point the guard runs — useful for post-login redirects. Search and update all guards:
grep -rn "CanMatchFn" src/
Step 6 — Check paramsInheritanceStrategy (Breaking Default Change)
Angular 22 changes paramsInheritanceStrategy from 'emptyOnly' to 'always'. Child routes now inherit parameters from all parent routes by default.
This is a quality-of-life improvement for most apps — you no longer need .parent?.parent?.snapshot.params chains to reach an ancestor's :id. But it is a breaking change if your app relied on child routes not inheriting parent params.
If your app relied on the old behavior, restore it explicitly:
import { provideRouter, withRouterConfig } from '@angular/router'
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withRouterConfig({
paramsInheritanceStrategy: 'emptyOnly' // restore Angular 21 behavior
}))
]
}
If you want to embrace the new default, audit your nested route components. Any place you previously had to climb the route tree to get a parameter will now just work:
// Before — had to climb the tree
const projectId = this.route.parent?.snapshot.params['projectId']
// After — inherited automatically
const projectId = this.route.snapshot.params['projectId']
Check your nested dashboard views, filter pages, and detail views for any routes that had implicit emptyOnly assumptions.
Step 7 — Fix data-* Binding Syntax (Breaking)
Angular 22 no longer treats data-* attributes as property bindings. This causes template compile errors.
Before (Angular 21 — now a compile error):
<div [data-id]="itemId"></div>
<tr [data-row-index]="i"></tr>
<button [data-action]="action"></button>
After (Angular 22):
<div [attr.data-id]="itemId"></div>
<tr [attr.data-row-index]="i"></tr>
<button [attr.data-action]="action"></button>
Search your templates:
grep -rn "\[data-" src/
Every result needs to be changed to [attr.data-*]. This commonly shows up in data tables, drag-and-drop implementations, and components that pass data to third-party libraries via data attributes.
Step 8 — Fix Duplicate Input Bindings (Breaking)
Angular 22 turns duplicate input bindings into a hard compile error. In Angular 21, some cases slipped through without error.
Before (Angular 21 — no error, silently used first value):
<app-card [title]="firstTitle" [title]="secondTitle"></app-card>
After (Angular 22 — compile error):
<app-card [title]="correctTitle"></app-card>
These are almost always a bug, not intentional — Angular 22 just surfaces them. Run ng build and address every duplicate binding error the compiler reports.
Step 9 — Test HTTP / File Upload (Default Changed)
Angular 22 switches HttpClient's internal implementation from XHR to the browser Fetch API. Your HTTP code doesn't change, but upload progress reporting may break because the Fetch API doesn't expose upload progress the same way XHR did.
How to check:
// If your app uses reportProgress: true with upload
this.http.post('/api/upload', formData, {
reportProgress: true,
observe: 'events'
}).subscribe(event => {
if (event.type === HttpEventType.UploadProgress) {
// This may no longer fire with the Fetch backend
this.progress = Math.round(100 * event.loaded / (event.total ?? 1))
}
})
If you rely on upload progress, switch back to the XHR backend explicitly while the Fetch implementation catches up:
import { provideHttpClient, withXhrClient } from '@angular/common/http'
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withXhrClient()) // force XHR
]
}
Step 10 — Change Detection Review
The CLI migration added ChangeDetectionStrategy.Eager to your existing components. Your app works. But now the real cleanup opportunity starts.
Each Eager in your codebase is a marker for a component not yet using the modern signal-based pattern. Work through them incrementally:
// Migration added this — it works but is "legacy" marker
@Component({
changeDetection: ChangeDetectionStrategy.Eager,
template: `{{ someObservable$ | async }}`
})
export class LegacyComponent {
someObservable$ = this.service.data$
}
// Modern approach — remove Eager, use signals
@Component({
// No changeDetection needed — OnPush is the new default
template: `{{ data() }}`
})
export class ModernComponent {
data = toSignal(this.service.data$)
}
Don't try to convert everything at once. Pick a low-risk component, convert it to signals, remove the Eager flag, verify it works, and repeat. This is ongoing work, not a one-migration task.
Complete Migration Checklist
Environment
☐ Node.js upgraded to 22+
☐ TypeScript upgraded to 6+
CLI Migration
☐ ng update @angular/core@22 @angular/cli@22 run successfully
☐ Migration output log reviewed
Breaking Changes
☐ ComponentFactoryResolver removed from all files
☐ ComponentFactory removed from all files
☐ provideRoutes() replaced with provideRouter()
☐ CanMatchFn guards updated with third currentSnapshot parameter
☐ data-* bindings changed to [attr.data-*]
☐ Duplicate input bindings resolved
Default Changes
☐ paramsInheritanceStrategy checked — set 'emptyOnly' if needed
☐ File upload tested — withXhrClient() added if needed
Post-Migration
☐ ng build runs clean (zero errors)
☐ All tests pass
☐ Manually tested critical paths (auth, nested routes, file upload)
☐ Eager markers noted for incremental signal migration
What Stayed the Same
These things did not change — you do not need to update them:
HttpClientcall syntax (get,post,put,delete)- Component and directive decorators
- Standalone component pattern
- Signals API (
signal,computed,effect,input) @angular/formsreactive forms- Template syntax (
@if,@for,@switch) - Dependency injection (
inject(), constructor injection)
Recommended Upgrade Order for Large Projects
For large projects with many modules, upgrade in this order to isolate issues:
- Node.js + TypeScript first
- Angular CLI alone —
ng update @angular/cli@22 - Angular Core —
ng update @angular/core@22 - Fix router issues in the root module/app config
- Fix dynamic component usages
- Fix template errors (data-*, duplicate bindings)
- Test HTTP layer, especially file uploads
- Run full test suite
- Deploy to staging, verify
- Begin incremental OnPush signal migration as a separate workstream
The worst thing to do is upgrade everything at once and debug a wall of errors with no idea which step caused which problem. One layer at a time gives you a clean rollback point at every step.
Frequently Asked Questions
Need a Full Bootstrap 5 Admin Dashboard?
Get a complete Angular 21 + Bootstrap 5 dashboard with 50+ components — built by the same team behind BootstrapPlanet.
Browse Templates →Use code FIRST30 for 30% off your first purchase.