Upgrading to Angular 22: What Changes for SSR Apps (and How to Migrate)
Angular 22 makes OnPush, the Fetch backend and incremental hydration the defaults, changes how router params are inherited and needs TypeScript 6. What that means for a server-rendered app, the ng update path from 20 or 21, and an after-upgrade checklist.

Angular 22 was released on 3 June 2026. It gets active support until June 2027 and long-term support (LTS) until June 2028. For a server-rendered app, five changes matter. Components with no changeDetection setting are now OnPush. HttpClient uses the Fetch API. Incremental hydration is on whenever you call provideClientHydration(). The router's paramsInheritanceStrategy now defaults to 'always'. And you need TypeScript 6.0 with Node.js ^22.22.3, ^24.15.0 or ^26.0.0. If you are still on Angular 20, its LTS ends on 28 November 2026, and it gets no security patches after that date. Below is what each change means for SSR, the ng update path from 20 or 21, and a checklist for after the upgrade.
Angular 22 at a glance: versions and support window
This is the official support table on 5 November 2026:
| Version | Released | Active support ends | LTS ends |
|---|---|---|---|
| 22 | 2026-06-03 | 2027-06 | 2028-06 |
| 21 | 2025-11-19 | 2026-06-03 | 2027-06 |
| 20 | 2025-05-28 | 2025-11-19 | 2026-11-28 |
Versions 2 to 19 are unsupported, and LTS only brings critical fixes and security patches. That matters for SSR apps: most Angular advisories in 2026 hit the server-side packages (listed in Patching a MEAN Stack).
Angular now plans one major a year (with four to six minors) instead of one every six months. v22.3 is expected around November 2026 and v23.0 around June 2027.
OnPush is the default, and the migration keeps your old behaviour
In v22, a component with no changeDetection setting uses OnPush. The old strategy now has a clearer name, ChangeDetectionStrategy.Eager. Default still exists as a deprecated alias for Eager. The ng update run includes a migration that adds Eager where a component had no strategy, so the app behaves exactly as it did before the update:
// What the migration leaves on a component that had no strategy
@Component({
selector: 'app-price-table',
changeDetection: ChangeDetectionStrategy.Eager,
templateUrl: './price-table.html',
})
export class PriceTable {}
The real work is removing those Eager lines one component at a time. An OnPush component only updates when an input changes, when an event fires inside it, or when a signal its template reads changes. Code that subscribes and then assigns to a plain field (this.items = data) will show stale data after hydration. Move that state into a signal, or use the async pipe, before you switch the component over.
On the server there is one more rule. Angular serialises the page once the app is stable, and in a zoneless app (the default since v21) only work Angular knows about counts. HttpClient requests are tracked for you. A promise from a third-party SDK is not, so wrap it in PendingTasks or the HTML will be sent before the data arrives:
import { Component, PendingTasks, inject, signal } from '@angular/core';
@Component({ selector: 'app-rates', template: '{{ rate() }}' })
export class Rates {
private readonly pendingTasks = inject(PendingTasks);
protected readonly rate = signal<number | null>(null);
constructor() {
// SSR waits until every pending task has finished before it serialises the page.
this.pendingTasks.run(async () => this.rate.set(await fetchRateFromSdk()));
}
}
ng update only rewrites your own source, so also test the screens that use third-party components.
HttpClient now uses fetch
FetchBackend is now the default HttpBackend, so withFetch() is deprecated: the API docs say it "is not required anymore". To go back to XMLHttpRequest, use withXhr(). The main reason to do that is upload progress, because the Fetch API does not produce upload progress events. The reportProgress option is deprecated too, and is replaced by reportUploadProgress and reportDownloadProgress.
The v22 release adds a schematic that rewrites provideHttpClient so an app can keep using the XHR backend. Check what it wrote. On the server you want fetch, and if no screen needs upload progress you can delete withXhr(). On this site the change was one line in app.config.ts:
// Angular 21
provideHttpClient(withFetch(), withInterceptors([publicSessionInterceptor])),
// Angular 22: fetch is the default backend
provideHttpClient(withInterceptors([publicSessionInterceptor])),
// Only where a screen needs upload progress events (for example an admin upload form)
provideHttpClient(withXhr(), withInterceptors([authInterceptor])),
The SSR guide also documents a limit worth knowing. By default, Angular limits each server-side HttpClient response body to 1 MB, and a larger one fails with NG02825. If a page really needs a bigger payload, raise the limit in the server config, and keep it as small as you can:
// app.config.server.ts
providers: [
provideServerRendering({ maxResponseBodySize: 2 * 1024 * 1024 }, withRoutes(serverRoutes)),
],
Incremental hydration is on by default
Since v22, provideClientHydration() turns on incremental hydration by itself. withIncrementalHydration() is deprecated, and Angular intends to remove it in v24. To opt out, use withNoIncrementalHydration(). Incremental hydration also turns on event replay, so you can remove withEventReplay() as well:
// Angular 21
provideClientHydration(
withHttpTransferCacheOptions({ includePostRequests: false }),
withIncrementalHydration(),
),
// Angular 22
provideClientHydration(withHttpTransferCacheOptions({ includePostRequests: false })),
// Opting out, if a hydrate trigger causes problems you cannot fix yet
provideClientHydration(withNoIncrementalHydration()),
In practice only @defer blocks with hydrate triggers behave differently. They are rendered on the server and load their JavaScript when the trigger fires, for example @defer (hydrate on viewport) for a footer. If you never added hydrate triggers, nothing visible changes.
Router changes to check by hand
paramsInheritanceStrategy now defaults to 'always'. Child routes now inherit all parameters, data and resolved data from their parent routes, where before this only happened for empty-path children. A child component can now see its parent's params and data. That matters if a child uses the same param name as its parent, or binds route data to inputs with withComponentInputBinding(). To restore the old behaviour while you test:
provideRouter(
routes,
withComponentInputBinding(),
withRouterConfig({ paramsInheritanceStrategy: 'emptyOnly' }),
),
canMatchguards: thecurrentSnapshotparameter ofCanMatchFn(and of theCanMatchinterface) is now required. The release ships a migration for it, but check class-based guards and any tests that call guards directly.TitleStrategy.getResolvedTitleForRoutenow returnsstring | undefinedinstead ofany, so a custom title strategy may need a fallback.provideRoutes()is gone. UseprovideRouter()or theROUTESmulti-token.
TypeScript 6 and Node.js requirements
| Angular | Node.js | TypeScript | RxJS |
|---|---|---|---|
| 22.x | ^22.22.3 || ^24.15.0 || ^26.0.0 | >=6.0.0 <6.1.0 | ^6.5.3 || ^7.4.0 |
| 21.x | ^20.19.0 || ^22.12.0 || ^24.0.0 | >=5.9.0 <6.0.0 | ^6.5.3 || ^7.4.0 |
| 20.2.x, 20.3.x | ^20.19.0 || ^22.12.0 || ^24.0.0 | >=5.8.0 <6.0.0 | ^6.5.3 || ^7.4.0 |
Angular 22 drops Node 20, which reached end of life on 30 April 2026 anyway. The TypeScript ranges of 21 and 22 do not overlap, so TypeScript moves in the same step as Angular. Update Node everywhere the app runs (CI, the Docker base image, your host), because the SSR server runs on it in production, not only the build. I compare the Node lines in Node.js LTS versions for production.
Removed APIs and other breaking changes
ComponentFactoryResolverandComponentFactoryare removed. Pass the component class toViewContainerRef.createComponent, or usecreateComponent.createNgModuleRefis removed. UsecreateNgModule.ChangeDetectorRef.checkNoChangesis removed. In tests, usefixture.detectChanges().- The Hammer.js integration is removed. If you rely on gestures, provide your own implementation.
getAngularLibandsetAngularLibare removed. UsegetAngularJSGlobalandsetAngularJSGlobal.- Forms
minandmaxrules no longer accept strings. Bind numbers ornull. data-attributes no longer bind to inputs or outputs, and an element matching several component selectors is now a compile error.rxResourceand otherstreamresources resolve synchronously when the observable emits synchronously, which can break tests.
Step by step: ng update from Angular 20 or 21
Update one major version at a time, each on a clean branch. The interactive Angular Update Guide adds steps for your exact versions.
- Record where you start: save the
ng versionoutput in the pull request. - From 20, go to 21 first (via the latest 20.3.x patch), then build, test and run the SSR server. Angular 21 made zoneless the default, so read the guide's Zone.js notes.
- Upgrade Node.js to a supported version, locally and in CI.
- Run the 22 update, then Material or other Angular packages. Check that
package.jsonnow lists TypeScript 6.0.x. - Review the migration diff:
Eageron components, the XHR schematic forprovideHttpClient, theCanMatchFnparameter, thestrictTemplatesentry intsconfigand the optional-chaining template migration. - Make the manual changes:
paramsInheritanceStrategy, removingwithFetch()andwithIncrementalHydration(), and any Hammer.js gestures. - Build and run the server bundle the way production does, not only
ng serve.
# Angular 20 -> 21 (skip if you are already on 21)
ng update @angular/cli@^21 @angular/core@^21 --create-commits
# Angular 21 -> 22
ng update @angular/cli@^22 @angular/core@^22 --create-commits
ng update @angular/material@^22 # if you use Angular Material / CDK
# Production build, then start the SSR server the way production does
ng build
node dist/website/server/server.mjs
--create-commits puts each update and migration in its own commit. That makes the migration diff easy to review, and easy to revert one piece at a time.
After the upgrade: an SSR checklist
Status codes from SSR still work
A "not found" page that is served with 200 OK becomes a soft 404 in search results. The RESPONSE_INIT token (from @angular/core) lets a component set the real status. It can be null during builds, client-side rendering and static site generation (prerendering), so inject it as optional. This is the not-found page on this site:
export class NotFoundPage {
constructor() {
const responseInit = inject(RESPONSE_INIT, { optional: true }); // null in the browser
if (responseInit) responseInit.status = 404;
inject(SeoService).updateFromRouteData({ title: 'Page not found', robots: 'noindex, follow' });
}
}
Check it from the outside after every upgrade:
curl -s -o /dev/null -w "%{http_code}\n" https://your-site.example/this-page-does-not-exist # expect 404
curl -s -o /dev/null -w "%{http_code}\n" https://your-site.example/ # expect 200
If you are unsure which code a page should return, the HTTP status codes reference explains each one, and Angular SSR and Signals in Practice shows the 404 and 503 pattern in a data-driven page.
The transfer cache still prevents double fetches
By default the transfer cache stores GET and HEAD requests without auth headers or credentials, and skips responses with Set-Cookie or no-store, no-cache or private. Load a server-rendered page with the network tab open: calls the server already made should not repeat in the browser. If you use withRequestsMadeViaParent(), you need @angular/common 22.1.1 or later (CVE-2026-88059).
@angular/build is on 22.2.5 or later
On 28 October 2026 the Angular team published GHSA-w34w-96hf-98hj, rated High (CVSS 4.0 score 7.7, no CVE). Route parameter values from getPrerenderParams() were written into the generated server manifests without escaping, so a crafted value could run code when the SSR server loaded the manifest. It applies when you prerender parameterised routes with server output mode. Affected: @angular/build 22.x before 22.2.5, 21.x before 21.2.29 and 20.x before 20.3.42. The Low-severity GHSA-jp2q-rh7r-4vjf (prerendered redirect pages) has the same fixed versions, and 22.2.6 was the latest release on 4 November.
npm ls @angular/build @angular/ssr @angular/core
@angular/build and @angular/ssr come from the angular-cli repository, so their patch numbers differ from @angular/core (core was on 22.2.4 when build reached 22.2.6). If you prerender from CMS data, also filter the params, as the advisory recommends:
{
path: 'blog/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const posts = inject(PostsApi); // inject() must run before the first await
const slugs = await posts.listSlugs();
return slugs.filter((slug) => /^[a-z0-9_-]+$/i.test(slug)).map((slug) => ({ slug }));
},
},
This site renders every route per request ({ path: '**', renderMode: RenderMode.Server }), so the precondition does not apply here, but the update is still worth taking.
No new hydration errors
In development mode, the browser console shows a message with hydration stats, including how many components and nodes were hydrated. NG0500 (Hydration Node Mismatch) means the browser found a different DOM from the one the server rendered. The usual causes are direct DOM manipulation (including innerHTML), invalid HTML, or a CDN or custom server code that strips the comment nodes Angular adds. ngSkipHydration works as a last resort, but treat it as a bug to fix later. If you see NG0506 ("application remains unstable"), something is keeping the app busy for more than 10 seconds. provideStabilityDebugging logs information when the app takes longer than expected to stabilise. Angular does not strip it from production bundles, so use it only temporarily.
Host validation is configured
Angular SSR checks the Host and forwarded-host headers against an allowlist (security.allowedHosts in angular.json, or NG_ALLOWED_HOSTS) and ignores X-Forwarded-* headers unless you trust them. Make sure your production hostnames are listed, or visitors get 400 Bad Request.
The risk in a major upgrade is a default that changes silently: a soft 404, a duplicate fetch, a stale OnPush view. If you want help moving an SSR app to Angular 22, see my Angular SSR development service.
Sources
- Angular: Versioning and releases (support table and release schedule)
- Angular: Version compatibility (Node.js, TypeScript and RxJS ranges)
- Angular 22.0.0 release notes (breaking changes and deprecations)
- Angular CHANGELOG (22.0.0 migrations and features)
- ChangeDetectionStrategy API, withFetch API, withIncrementalHydration API
- Angular: Setting up HttpClient
- Angular: Incremental hydration and Hydration
- Angular: Server-side and hybrid rendering (RESPONSE_INIT, transfer cache, maxResponseBodySize)
- Angular: Security (allowedHosts and trusted proxy headers)
- Angular: Zoneless (PendingTasks)
- Angular CLI: ng update and the Angular Update Guide
- NG0500: Hydration Node Mismatch
- GHSA-w34w-96hf-98hj and GHSA-jp2q-rh7r-4vjf (@angular/build)
- GHSA-p297-fm68-3q8c (HttpTransferCache with withRequestsMadeViaParent)
- angular-cli releases (22.2.5 and 22.2.6)
- Node.js release schedule (Node 20 end of life)
Related posts
Free tools for this topic
- JSON to TypeScriptPaste JSON and get TypeScript interfaces or types with nested objects, arrays and optional fields. Free, fast and runs in your browser.
- HTTP Status CodesEvery common HTTP status code explained: what 200, 301, 404, 429, 500 and 503 mean, why they happen, Express fixes and the SEO impact.




