Ionic 4 shipped in early 2019 and Ionic 5 in 2020. Both are long out of support, yet a lot of revenue still runs through apps built on them. This guide is for Angular teams on Ionic 4 or 5 who need to land on Ionic 8 (the current long-lived line) or Ionic 9 (released August 19, 2026). The numbers look scary — four or five major versions — but most of the work is Angular's, not Ionic's, and Ionic now ships a migration tool that handles the mechanical part of the final hop.
Step 0: build the harness before you touch a version number
You cannot judge an upgrade without a baseline. Before upgrading anything:
- Make sure
ng build --configuration productionsucceeds and record the warnings - Write (or revive) end-to-end smoke tests for the five or six user journeys
that matter commercially. Playwright running against
ionic serveis the cheapest way; a handful of tests is enough - Take screenshots of every major screen on iOS and Android. Ionic's visual changes across majors (iOS 13+ styling in v5, the Ionic 7 overlay and toolbar changes, Ionic 8's dark-mode and high-contrast palette) are real, and you will want to know whether a difference is a regression or a new default
Step 1: climb Angular and Ionic together, one major at a time
Ionic majors pin Angular ranges. Going straight from Ionic 4 + Angular 8 to
Ionic 9 + Angular 21 skips migrations that ng update and the Ionic
changelogs only apply incrementally. Our sequence:
| From | To | What changes |
|---|---|---|
| Ionic 4 / Angular 8–9 | Ionic 5 / Angular 9–10 | iOS 13 visual refresh; ion-slides and icon changes; @ionic/angular 5 requires Ivy |
| Ionic 5 | Ionic 6 / Angular 12–14 | Overlay components move to ion-* inline usage; ion-datetime rewrite; ion-modal sheet/card; ionicons 6 |
| Ionic 6 | Ionic 7 / Angular 14–16 | ion-slides removed (use Swiper directly); component property renames; modal and popover presentation changes |
| Ionic 7 | Ionic 8 / Angular 16–19+ | Dark and high-contrast palettes become opt-in stylesheets; ion-nav and overlay typing tightened; @ionic/angular/standalone matures |
| Ionic 8 | Ionic 9 / Angular 18–22 | Zoneless by default on Angular 21+; standalone becomes the default import path; lazy imports move to @ionic/angular/lazy |
Each row has an official page under
ionicframework.com/docs/updating.
Read it, run ng update @angular/core@N @angular/cli@N for the matching
Angular major, run the tests, commit, repeat. Do not batch two hops in one
branch; when something breaks you want to know which hop did it.
Commands per hop
# Example: Ionic 6 -> 7 with Angular 14 -> 16
ng update @angular/core@16 @angular/cli@16
npm install @ionic/angular@7 @ionic/angular-toolkit@latest ionicons@latest
npm run build && npx playwright test
Step 2: the breaking changes that actually bite
From a decade of these upgrades, the items that consume the most time:
ion-slidesremoval (Ionic 7). Replace with Swiper's Angular components. Budget half a day per screen that used slides heavily.ion-datetimerewrite (Ionic 6). The old wheel picker became an inline calendar;displayFormatandpickerFormatare gone. Wrap it in a modal or popover if your design needs the old interaction.- Overlays (Ionic 6–7).
ion-modalwith[isOpen],presentingElement, andbreakpointsreplaces mostModalControllerboilerplate; type-safecomponentPropsrequire cleanup. - Theming (Ionic 8). Dark mode is no longer automatic via the
prefers-color-schemevariables you pasted from the v4 docs; import@ionic/angular/css/palettes/dark.system.css(ordark.class.cssfor a toggle). Re-check every hard-coded colour. - Icons.
ionicons5+ renamed and removed icons; run a grep for<ion-icon name=and check the list. - RxJS and TypeScript. Angular majors drag these along;
rxjs-compatremoval andstricttemplate checks surface real bugs.
Step 3: standalone components
Ionic 8's @ionic/angular/standalone is the path forward, and in Ionic 9 it
is the default: @ionic/angular is the standalone entry point and the old
lazy-loaded modules live at @ionic/angular/lazy. Migrate after you are on
Ionic 8 and a modern Angular:
ng generate @angular/core:standalone
Angular's schematic converts components, removes NgModules, and bootstraps
the app with bootstrapApplication. Then switch Ionic to per-component
imports:
import { Component } from '@angular/core';
import {
IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel,
} from '@ionic/angular/standalone';
@Component({
selector: 'app-orders',
standalone: true,
imports: [IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel],
templateUrl: './orders.page.html',
})
export class OrdersPage {}
and bootstrap with provideIonicAngular():
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideIonicAngular } from '@ionic/angular/standalone';
bootstrapApplication(AppComponent, {
providers: [provideIonicAngular({ mode: 'ios' }), provideRouter(routes)],
});
Tree-shaking now works per component, which is typically the single biggest bundle-size win of the whole upgrade.
Step 4: signals and zoneless
Angular signals (stable since Angular 17) and Ionic 9's zoneless default change how state updates reach the template. Under Zone.js, this worked:
async openFilters() {
const modal = await this.modalCtrl.create({ component: FiltersPage });
await modal.present();
const { data } = await modal.onWillDismiss();
this.filters = data; // Zone.js triggered change detection
}
Without Zone.js, Angular does not know this.filters changed. Make the state
a signal and the template updates regardless of how the value arrived:
import { signal } from '@angular/core';
filters = signal<Filters | null>(null);
async openFilters() {
const modal = await this.modalCtrl.create({ component: FiltersPage });
await modal.present();
const { data } = await modal.onWillDismiss<Filters>();
this.filters.set(data ?? null);
}
Do the same for setTimeout callbacks, RxJS subscriptions that write to
fields (or use toSignal()), and Ionic Platform events. Angular's
signals guide covers the patterns; Ionic's
zoneless guide covers the overlay cases. You can stay on Zone.js in Ionic 9
if you need to — it is still supported — but new code should not depend on
it.
Step 5: the final hop with @ionic/migrate
For Ionic 8 → 9, Ionic ships a migration tool that applies the safe breaking changes and lists the rest with file and line numbers:
git status # must be clean; the tool refuses to run on a dirty tree
npx @ionic/migrate --dry-run
npx @ionic/migrate
It reads your framework and version from package.json, rewrites imports
(@ionic/angular/standalone → @ionic/angular, lazy imports →
@ionic/angular/lazy), bumps the @ionic/* packages, runs Prettier on what
it touched, and reinstalls dependencies. Use --check in CI to block merges
while migrations are pending. It is single-shot per major, so run it once,
review the diff, then work through the printed checklist.
Step 6: after the upgrade
- Run the screenshot comparison from Step 0 and triage every difference
- Re-run
ng build --configuration productionand compare bundle sizes - Update the Capacitor major at the same time if you are behind — our Cordova → Capacitor checklist covers the runtime side
- Record the Ionic and Angular versions and the next planned upgrade window in the repo's README; the reason this app got stuck on Ionic 4 is that nobody wrote that line in 2019
When to call for help
If the app is on Ionic 4 with AngularJS remnants (an Ionic 1 app that was
"half migrated"), or the team that wrote it is gone, an incremental rewrite
may be cheaper than a five-hop upgrade. That is the call we help teams make on
our legacy modernization engagements, and we
are happy to look at a package.json and give you an honest read.