Upgrading Our Angular Projects to v22

Upgrading Our Angular Projects to v22

frontend Published 9 min read

Angular 22 landed in June, and I upgraded 2 of my apps to it, one from v19 and one from v20. The migrations themselves were straightforward. What blocked us was everything around them: Node.js versions, dependency compatibility and SSR configuration.

Here's the order we went in, what we had to change by hand, and how we checked the result. The 2 apps:

Before Running ng update

The Node.js Floor

Angular 22 raises the minimum Node.js version, and the range is stricter than it looks:

"engines": {
  "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}

That rules out Node 20, Node 22 below 22.22.3, and anything on 24 below 24.15.0.

Both apps were below that floor. The task board's Volta pin and CI matrix were on 24.7.0, which looks recent enough and isn't, and angular-optimization was still pinned to 20.18.0.

So we raised both pins and both CI configs before anything else. Otherwise the upgrade fails on engines before it fails on anything interesting.

Peer Ranges That Block the Upgrade

ng update checks peer ranges before it migrates anything. Any package in the tree that caps Angular at the old major stops it right there, and we had 2 of them.

The test stack. Both apps run Vitest through Analog, and Analog 1.x caps its @angular/build peer at ^20, so it blocked ng update before the Angular migration could even start.

We moved to Analog 2, Vitest 4 and Vite 7 first, while still on the old Angular version. The 41 tests of the task board stayed green on v20, and only then did we touch Angular itself.

NgRx Signals. @ngrx/signals 22 only shipped on 24 August, almost 3 months after Angular 22. The 21 line peers on @angular/core ^21, so until last week a Signal Store app on Angular 22 was running a pair NgRx didn't support.

If your state library isn't in step with Angular yet, check it before anything else.

💡 Upgrade whatever pins Angular's peers first, on the old major, so the Angular upgrade commit only contains the Angular upgrade.

TypeScript Goes to 6.0, Not to Latest

@angular/build 22 peers typescript: ">=6.0 <6.1", while typescript@latest on npm has been 7.0.2 since early July. A blind bump to latest therefore puts us outside the supported range, so we pin it:

pnpm add -D typescript@~6.0.3

The lint stack needs a look too, since typescript-eslint below 8.58 caps TypeScript under 6.0 and refuses to run against it.

One Major at a Time

ng update won't skip majors, and that's what we want anyway, since every major ships its own migrations. For the task board, which started on v20, it looked like this:

npx ng update @angular/cli@21 @angular/core@21
npx ng update @ngrx/signals@21
npx ng update @angular/cli@22 @angular/core@22
npx ng update @ngrx/signals@22

What each step did:

  1. Angular 21 ran its migrations and surfaced an old mistake in the task board. main.ts inlined its own providers instead of using appConfig, so the browser build never got provideClientHydration(withEventReplay()) while the server build did. Both entry points now bootstrap from the one config.
  2. NgRx Signals 21 renamed withEffects to withEventHandlers and removed the old name rather than aliasing it, so v20 store code won't compile on v21. The schematic rewrote both call sites for us, which is a good reason to run ng update rather than editing package.json by hand.
  3. Angular 22, with the changes the rest of this post is about.
  4. NgRx Signals 22 last, back in step with Angular.

angular-optimization started on v19, so it took 3 rounds, with one commit per major. The v20 round moved its server config to provideServerRendering(withRoutes(serverRoutes)) and passed a BootstrapContext through main.server.ts, both by migration.

A commit per major pays off when something breaks later, since it points at one version instead of three.

What the Migrations Leave Behind

OnPush Is the Default, and Eager Is Everywhere

In v22 a component without a changeDetection property is OnPush. To keep existing apps behaving as before, the migration stamps the old behaviour onto every component that didn't declare a strategy:

@Component({
  selector: 'app-pokedex',
  // …
  changeDetection: ChangeDetectionStrategy.Eager,
})

Eager is the new name for what used to be Default, and in angular-optimization the migration added it to 13 components.

That's where the linter stepped in. angular-eslint 22.1 made prefer-on-push-component-change-detection part of its recommended config, and the rule now reports exactly one thing: components that opt out of OnPush. So right after the migration, it flagged every line the migration had just written.

We removed Eager rather than silence the rule. In the task board that was all it took, because the form, the columns and the buttons all change state through template event bindings, and those mark the component dirty on their own.

angular-optimization needed one more step, since some of its state changes outside any template binding:

public cols = signal(1);
public rowHeight = signal('380px');

ngOnInit() {
  this.breakpointObserver
    .observe([Breakpoints.XSmall, Breakpoints.Small /* … */])
    .subscribe(result => {
      if (result.breakpoints[Breakpoints.XSmall]) {
        this.cols.set(this.gridByBreakpoint.xs);
        this.rowHeight.set('250px');
      }
      // …
    });
}

cols and rowHeight used to be plain fields assigned inside that subscription. With Eager and zone.js, the next change detection pass picked them up.

Under OnPush nothing marks the component dirty when a subscription fires, so the grid would keep its first layout. As signals, the template reads them and Angular knows exactly when they change. The form toggle on the same page got the same treatment.

💡 Look for fields assigned inside subscribe, timers or listeners you register by hand. Those are the ones OnPush stops picking up.

Providers That Are Now Defaults

Incremental hydration and the fetch backend are defaults in v22, and withFetch is deprecated outright. The app config of angular-optimization had all of this:

provideClientHydration(withIncrementalHydration()),
provideHttpClient(withFetch()),
provideAnimationsAsync(),

and now it's down to:

// Incremental hydration and the fetch backend are the defaults since v22.
provideClientHydration(),
provideHttpClient(),

provideAnimationsAsync() went too, together with the @angular/animations package, since Angular Material no longer needs it. We also dropped @angular/platform-browser-dynamic, which has been deprecated since v20 and was only still there for the test setup.

SSR

An Unknown Host Now Gets a 400

Angular's SSR engine checks the Host header against an allow list. Before v22, a request from a host it didn't know about quietly fell back to client-side rendering. From v22 it gets a 400 Bad Request instead.

So the first time we ran the task board's SSR build locally, every request answered with:

Header "host" with value "localhost:4000" is not allowed.

We added localhost to the list in angular.json:

"security": {
  "allowedHosts": ["localhost"]
}

Please use that one with care on deploy. The real hostname needs to be in there too, or set through NG_ALLOWED_HOSTS. Otherwise production answers 400 the same way, and nothing falls back anymore.

On Netlify: Runtime v4

angular-optimization deploys to Netlify, and the upgrade needed @netlify/angular-runtime v4.

v4 removes the @netlify/angular-runtime/context import our old Express-based server.ts relied on, so that file had to go. We replaced it with the runtime's App Engine handler:

import { AngularAppEngine, createRequestHandler } from '@angular/ssr';
import {
  getAllowedHosts,
  getContext,
  getTrustProxyHeaders,
} from '@netlify/angular-runtime/app-engine.js';

const angularAppEngine = new AngularAppEngine({
  allowedHosts: getAllowedHosts(),
  trustProxyHeaders: getTrustProxyHeaders(),
});

export async function netlifyAppEngineHandler(request: Request): Promise<Response> {
  const result = await angularAppEngine.handle(request, getContext());
  return result || new Response('Not found', { status: 404 });
}

export const reqHandler = createRequestHandler(netlifyAppEngineHandler);

3 things changed with it:

  1. Netlify runs this module as an Edge Function on Deno, so Express and anything Node-only had to go, and with them the serve:ssr script, which by then only served static files.
  2. The runtime hands over the host allow list and the proxy-header setting, so the 400 from the previous section is covered on deploy.
  3. reqHandler keeps ng serve rendering on the server locally, through the same handler.

Going Zoneless

Zoneless has been the default for new projects since v21. Once every component was fine under OnPush, removing zone.js from angular-optimization was mostly deletion: the polyfill entry in angular.json, the package, and provideZoneChangeDetection() in the app config.

It showed in the bundle too. The initial bundle went from 669 kB to 626 kB, which is below the 640 kB it had before the upgrade started.

The tests needed their own changes:

// jsdom does not implement IntersectionObserver, which `@defer (on viewport)` relies on.
class IntersectionObserverStub {
  observe(): void {}
  unobserve(): void {}
  disconnect(): void {}
  takeRecords(): IntersectionObserverEntry[] {
    return [];
  }
}
globalThis.IntersectionObserver ??=
  IntersectionObserverStub as unknown as typeof IntersectionObserver;

setupTestBed({ zoneless: true });

Analog's setupTestBed({ zoneless: true }) replaces the zone-based test setup. The specs that used fakeAsync, which needs zone.js, moved to whenStable().

The stub is there because jsdom has no IntersectionObserver, and the @defer (on viewport) blocks on the pages need one.

Run It in a Browser

Build, lint and tests all passed on both apps, and we still drove them in a browser, with Playwright against ng serve, SSR on and the Pokémon API mocked.

In angular-optimization that meant 13 checks, from server rendering and the deferred Pokémon list to the dialogs and the mobile layout. None of them hit a runtime or hydration error.

What to Watch Out For

  1. Raise Node.js first, to 22.22.3, 24.15.0 or 26 and above, in the version pin and in the CI matrix.
  2. Move anything that pins Angular's peers on the old major, before ng update: the test stack, the lint stack, and NgRx.
  3. Pin TypeScript to ~6.0, not to latest.
  4. Run ng update one major at a time, and commit each one on its own.
  5. Decide what to do with Eager. Keep it and silence the lint rule, or remove it and move state that changes outside templates to signals.
  6. Put every real hostname into allowedHosts before deploying SSR, since there's no client-side fallback anymore.
  7. On Netlify, move to runtime v4 and its App Engine handler.
  8. Drive the app in a browser before calling it done.

If you'd rather see Angular built up end to end with a real backend behind it, the second edition of Spring Boot and Angular, which I co-authored with Ahmad Gohar, comes out in September. It pairs Spring Boot microservices with Angular v22 and NgRx Signals 22, the same versions this post ends on.

Cover of Spring Boot and Angular, second edition, by Ahmad Gohar and Dimitrios Kyriakakis

Second edition, Spring Boot microservices with Angular v22 and NgRx Signals 22.

You can pre-order it on Amazon.

Resources