# Upgrading Our Angular Projects to v22

> Two Angular apps, one on v19 and one on v20, taken to v22 one major at a time. What stopped us first was never our own code: a Node pin, two peer ranges and a Host header.

Canonical: https://www.dimeloper.com/blog/upgrading-angular-projects-to-v22/
Published: 2026-08-30T09:00:00.000Z

[Angular](https://angular.dev/) 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:

- **[task-tracker-ngrx](https://github.com/dimeloper/task-tracker-ngrx)**, the task board from my [state management articles](https://www.dimeloper.com/blog/event-driven-ngrx-signal-store/), on v20
- **[angular-optimization](https://github.com/dimeloper/angular-optimization)**, a Pokémon app I use to show Angular performance techniques, on v19

## Before Running ng update

### The Node.js Floor

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

```json
"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](https://volta.sh/) 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](https://vitest.dev/) through [Analog](https://analogjs.org/), 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](https://vite.dev/) 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`](https://ngrx.io/guide/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:

```bash
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:

```bash
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:

```ts
@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](https://github.com/angular-eslint/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:

```ts
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:

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

and now it's down to:

```ts
// 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](https://material.angular.dev/) 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`:

```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](https://www.netlify.com/), 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:

```ts
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:

```ts
// 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](https://playwright.dev/) 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](https://www.linkedin.com/in/ahmadgohar), comes out in September. It pairs Spring Boot microservices with Angular v22 and NgRx Signals 22, the same versions this post ends on.

<figure>

[*[Image: Cover of Spring Boot and Angular, second edition, by Ahmad Gohar and Dimitrios Kyriakakis]*](https://www.amazon.com/Spring-Boot-Angular-Hands-development-ebook/dp/B0G394CTV7)

<figcaption>Second edition, Spring Boot microservices with Angular v22 and NgRx Signals 22.</figcaption>

</figure>

You can [pre-order it on Amazon](https://www.amazon.com/Spring-Boot-Angular-Hands-development-ebook/dp/B0G394CTV7).

## Resources

- [Using NgRx Signal Store](https://www.dimeloper.com/blog/ngrx-signal-store-state-management/) (the method-based store the task board started with)
- [Event-Driven State Management with NgRx Signal Store](https://www.dimeloper.com/blog/event-driven-ngrx-signal-store/) (the events plugin it runs on now)
- [task-tracker-ngrx, the Angular 22 upgrade and Signal Forms (PR #9)](https://github.com/dimeloper/task-tracker-ngrx/pull/9)
- [angular-optimization, update project to Angular v22 (PR #15)](https://github.com/dimeloper/angular-optimization/pull/15)
- [Angular update guide](https://angular.dev/update-guide)
- [Announcing Angular v22](https://blog.angular.dev/announcing-angular-v22-c52bb83a4664)
