Skip to main content
Version: v9

Updating from Ionic 8 to 9

note

This guide assumes that you have already updated your app to the latest version of Ionic 8. Make sure you have followed the Upgrading to Ionic 8 Guide before starting this guide.

Breaking Changes

For a complete list of breaking changes from Ionic 8 to Ionic 9, please refer to the breaking changes document in the Ionic Framework repository.

Getting Started

Angular

  1. Ionic 9 supports Angular 18 through 22. Angular 16 and 17 are no longer supported. Update to a supported version of Angular by following the Angular Update Guide.

  2. Update to the latest version of Ionic 9:

npm install @ionic/angular@latest

If you are using Ionic Angular Server and Ionic Angular Toolkit, be sure to update those as well:

npm install @ionic/angular@latest @ionic/angular-server@latest @ionic/angular-toolkit@latest

Zoneless Change Detection

Ionic 9 supports zoneless change detection. Angular 21 made zoneless the default, so a new Ionic 9 app on Angular 21 or later runs without Zone.js out of the box and no change-detection provider is required.

Without Zone.js, Angular does not automatically re-render when you update component state from an asynchronous callback (awaiting an overlay result, setTimeout, RxJS subscriptions, Platform events). In those cases you must notify Angular with a signal or ChangeDetectorRef.markForCheck(). Refer to Zoneless Change Detection for the patterns Ionic apps use.

note

On Angular 18 through 20, Zone.js remains Angular's default, so those versions are unaffected and require no action. To adopt zoneless on Angular 18 through 20, add provideZonelessChangeDetection() (named provideExperimentalZonelessChangeDetection() on Angular 18 and 19) and remove zone.js from the polyfills array in angular.json.

Keeping Zone.js

If you prefer to keep using Zone.js on Angular 21 or later, opt back in with provideZoneChangeDetection().

For standalone applications, add the provider to bootstrapApplication:

import { bootstrapApplication } from '@angular/platform-browser';
+ import { provideZoneChangeDetection } from '@angular/core';

bootstrapApplication(AppComponent, {
providers: [
+ provideZoneChangeDetection(),
// ...other providers
],
});

For NgModule applications, pass it as applicationProviders on bootstrapModule(). Angular does not allow provideZoneChangeDetection() inside an NgModule's providers array:

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
+ import { provideZoneChangeDetection } from '@angular/core';

platformBrowserDynamic()
- .bootstrapModule(AppModule)
+ .bootstrapModule(AppModule, {
+ applicationProviders: [provideZoneChangeDetection()],
+ })
.catch((err) => console.error(err));

Either way, also confirm zone.js is listed in the polyfills array in angular.json. Angular 21 and later default scaffolds omit it:

angular.json
"polyfills": ["zone.js"]

OnPush Change Detection on Angular 22

Angular 22 changes the default change detection strategy to OnPush for components that don't declare one. Combined with the zoneless default above, component state you mutate as a plain field from an Ionic lifecycle hook (ionViewWillEnter, and so on) no longer re-renders on its own.

Run ng update when upgrading to Angular 22; it migrates your existing components to eager change detection and preserves the previous behavior. To write OnPush-ready components instead, set the state through a signal or call ChangeDetectorRef.markForCheck() in the hook:

+ import { signal } from '@angular/core';
+
- entered = 0;
-
- ionViewWillEnter() {
- this.entered++;
- }
+ entered = signal(0);
+
+ ionViewWillEnter() {
+ this.entered.update((count) => count + 1);
+ }
note

Ionic's own Angular components already declare OnPush, so they are unaffected. Angular 18 through 21 keep the eager default and require no change.

TypeScript

Ionic 9 supports TypeScript 5.4 or later, matching the minimum for Angular 18. Angular 21 requires TypeScript 5.9 or later, and Angular 22 requires TypeScript 6.0 or later.

Node.js

Angular 22 raises the minimum Node.js version to ^22.22.3 || ^24.15.0 || ^26.0.0. Angular 18 through 21 are unaffected.

Component Imports

Ionic 9 makes standalone components the default import path. Change lazy-loaded component imports from @ionic/angular to @ionic/angular/lazy. Change standalone component imports from @ionic/angular/standalone to @ionic/angular.

IonicModule Deprecation

IonicModule is deprecated in Ionic 9 and will be removed in a future major version. It remains fully functional, so no immediate action is required. When you are ready, migrate to provideIonicAngular(), which works in both standalone and NgModule-based apps. Refer to Migrating from Modules to Standalone.

CSS Imports

Remove the ~ prefix from @ionic/angular CSS imports. Angular's current build pipeline no longer supports the webpack-loader prefix:

- @import '~@ionic/angular/css/core.css';
+ @import '@ionic/angular/css/core.css';

Module Resolution

If your app uses TypeScript moduleResolution: "node" (classic), imports from subpaths such as @ionic/angular/lazy can fail to resolve. Set moduleResolution to "bundler" in your tsconfig.json. Apps created with ng new on Angular 17 or later already use this.

React

  1. Ionic 9 supports React 18+. Update to the latest version of React:
npm install react@latest react-dom@latest
  1. Update to the latest version of Ionic 9:
npm install @ionic/react@latest @ionic/react-router@latest

React Router

  1. Ionic 9 supports React Router 6. Update to version 6 of React Router:
npm install react-router@6 react-router-dom@6
  1. If you have @types/react-router or @types/react-router-dom installed, remove them. React Router 6 includes its own TypeScript definitions:
npm uninstall @types/react-router @types/react-router-dom

Ionic React now requires React Router v6, which has a different API from v5. Below are the key changes you'll need to make.

Route Definition Changes

The component and render props have been replaced with the element prop, which accepts JSX:

- <Route path="/home" component={Home} exact />
+ <Route path="/home" element={<Home />} />

Routes can no longer render content via nested children. All route content must be passed through the element prop:

- <Route path="/">
- <Home />
- </Route>
+ <Route path="/" element={<Home />} />

Redirect Changes

The <Redirect> component has been replaced with <Navigate>:

- import { Redirect } from 'react-router-dom';
+ import { Navigate } from 'react-router-dom';

- <Redirect to="/home" />
+ <Navigate to="/home" replace />

Nested Route Paths

Routes that contain nested routes or child IonRouterOutlet components need a /* suffix to match sub-paths:

- <Route path="/tabs" element={<Tabs />} />
+ <Route path="/tabs/*" element={<Tabs />} />

Accessing Route Parameters

Route parameters are now accessed via the useParams hook instead of props:

- import { RouteComponentProps } from 'react-router-dom';
+ import { useParams } from 'react-router-dom';

- const MyComponent: React.FC<RouteComponentProps<{ id: string }>> = ({ match }) => {
- const id = match.params.id;
+ const MyComponent: React.FC = () => {
+ const { id } = useParams<{ id: string }>();

RouteComponentProps Removed

The RouteComponentProps type and its history, location, and match props are no longer available in React Router v6. Use the equivalent hooks instead:

  • history -> useNavigate (see below) or useIonRouter
  • match.params -> useParams (covered above)
  • location -> useLocation
- import { RouteComponentProps } from 'react-router-dom';
+ import { useNavigate, useLocation } from 'react-router-dom';
+ import { useIonRouter } from '@ionic/react';

- const MyComponent: React.FC<RouteComponentProps> = ({ history, location }) => {
- history.push('/path');
- history.replace('/path');
- history.goBack();
- console.log(location.pathname);
+ const MyComponent: React.FC = () => {
+ const navigate = useNavigate();
+ const router = useIonRouter();
+ const location = useLocation();
+ // In an event handler or useEffect:
+ navigate('/path');
+ navigate('/path', { replace: true });
+ router.goBack();
+ console.log(location.pathname);

Exact Prop Removed

The exact prop is no longer needed. React Router v6 routes match exactly by default. To match sub-paths, use a /* suffix on the path:

- <Route path="/home" exact />
+ <Route path="/home" />

Render Prop Removed

The render prop has been replaced with the element prop:

- <Route path="/foo" render={(props) => <Foo {...props} />} />
+ <Route path="/foo" element={<Foo />} />

Programmatic Navigation

The useHistory hook has been replaced with useNavigate:

- import { useHistory } from 'react-router-dom';
+ import { useNavigate } from 'react-router-dom';
+ import { useIonRouter } from '@ionic/react';

- const history = useHistory();
+ const navigate = useNavigate();
+ const router = useIonRouter();

- history.push('/path');
+ navigate('/path');

- history.replace('/path');
+ navigate('/path', { replace: true });

- history.goBack();
+ router.goBack();

Custom History Prop Removed

The history prop has been removed from IonReactRouter, IonReactHashRouter, and IonReactMemoryRouter. React Router v6 routers no longer accept custom history objects.

- import { createBrowserHistory } from 'history';
- const history = createBrowserHistory();
- <IonReactRouter history={history}>
+ <IonReactRouter>

For IonReactMemoryRouter (commonly used in tests), use initialEntries instead:

- import { createMemoryHistory } from 'history';
- const history = createMemoryHistory({ initialEntries: ['/start'] });
- <IonReactMemoryRouter history={history}>
+ <IonReactMemoryRouter initialEntries={['/start']}>

IonRedirect Removed

The IonRedirect component has been removed. Use React Router's <Navigate> component instead:

- import { IonRedirect } from '@ionic/react';
- <IonRedirect path="/old" to="/new" exact />
+ import { Navigate } from 'react-router-dom';
+ <Route path="/old" element={<Navigate to="/new" replace />} />

Path Regex Constraints Removed

React Router v6 no longer supports regex constraints in path parameters (e.g., /:tab(sessions)). Use literal paths instead:

- <Route path="/:tab(sessions)" component={SessionsPage} />
- <Route path="/:tab(sessions)/:id" component={SessionDetail} />
+ <Route path="/sessions" element={<SessionsPage />} />
+ <Route path="/sessions/:id" element={<SessionDetail />} />

IonRoute API Changes

The IonRoute component follows the same API changes as React Router's <Route>. The render prop has been replaced with element, and the exact prop has been removed:

- <IonRoute path="/foo" exact render={(props) => <Foo {...props} />} />
+ <IonRoute path="/foo" element={<Foo />} />

For more information on migrating from React Router v5 to v6, refer to the React Router v6 Upgrade Guide.

Vue

  1. Ionic 9 supports Vue 3.5+. Update to the latest version of Vue:
npm install vue@latest
  1. Update to the latest version of Ionic 9:
npm install @ionic/vue@latest @ionic/vue-router@latest

Vue Router

  1. Ionic 9 supports Vue Router 5. Update to the latest version of Vue Router:
npm install vue-router@latest

@ionic/vue-router now requires Vue Router v5. Vue Router v4 is no longer supported. Vue Router v5 also raises its peer requirement on Vue itself, so the minimum supported Vue version moves to 3.5.0.

Vue Router v5 is a transition release that ships no runtime breaking changes for Vue Router v4 consumers, so no application code changes are required for routes, navigation guards, or IonRouterOutlet.

Deprecation Warning for next() in Navigation Guards

Vue Router v5 prints a deprecation warning when next() is called inside beforeRouteLeave, beforeRouteEnter, beforeRouteUpdate, or router.beforeEach. The callback form still works, but Vue Router v6 will remove it. Migrate to the return-value pattern:

  // Composition API
onBeforeRouteLeave((to, from) => {
- if (!confirm('Leave?')) return next(false);
- next();
+ if (!confirm('Leave?')) return false;
+ return true;
});
  // Options API
- beforeRouteLeave(to, from, next) {
- if (!confirm('Leave?')) return next(false);
- next();
+ beforeRouteLeave(to, from) {
+ if (!confirm('Leave?')) return false;
+ return true;
}

For more information on migrating from Vue Router v4 to v5, refer to the Vue Router v4-to-v5 migration guide.

Core

  1. Update to the latest version of Ionic 9:
npm install @ionic/core@latest

Package Exports

@ionic/core's package.json now declares an exports field. This fixes subpaths like @ionic/core/components and @ionic/core/loader failing under Node ESM with ERR_UNSUPPORTED_DIR_IMPORT. The strict ESM resolver doesn't read the nested package.json files the package previously relied on, and the exports field replaces them. This affects toolchains such as Angular 21's default Vitest builder and raw Node.

The exports field defines the supported public entry points, and imports of paths it doesn't cover will fail. If your app uses Node ESM, webpack 5, or TypeScript moduleResolution: "bundler", "node16", or "nodenext" and imports from an unsupported path, switch to one of the subpaths below:

SubpathUse
@ionic/coreRoot entry, controllers, animation builders
@ionic/core/componentsCustom-element constructors and shared utilities
@ionic/core/components/ion-*.jsSingle-component custom-element constructor
@ionic/core/loaderdefineCustomElements lazy loader
@ionic/core/hydrateSSR hydration entry
@ionic/core/css/*.cssGlobal stylesheets and palettes

Apps on moduleResolution: "node" (classic) and webpack 4 keep resolving through the legacy fields and need no changes.

Required Changes

Browser Support

The list of browsers that Ionic supports has changed. Review the Browser Support Guide to ensure you are deploying apps to supported browsers.

If you have a browserslist or .browserslistrc file, update it with the following content:

Chrome >=89
ChromeAndroid >=89
Firefox >=75
Edge >=89
Safari >=16
iOS >=16

Capacitor

Ionic 9 officially supports Capacitor 7 and later. Native platform detection no longer falls back to the Capacitor 2 isNative flag; isCapacitorNative now relies solely on Capacitor.isNativePlatform(), which was added in Capacitor 3.

If your app is still on Capacitor 2, it will no longer be detected as running on a native platform, so isPlatform('capacitor'), isPlatform('hybrid'), and getPlatforms() will report web instead of native. Upgrade to Capacitor 7 or later by following the Capacitor updating guides.

Img

ion-img is deprecated and will be removed in Ionic 10. The component was created to lazy-load images before browsers supported lazy loading natively. Modern browsers now support the loading="lazy" attribute on the native <img> element, so the component is no longer needed.

Replace ion-img with a native <img> tag. Add loading="lazy" for lazy loading, and decoding="async" to match the asynchronous decoding ion-img applied by default. The alt and src properties map directly to the native attributes of the same name:

- <ion-img src="/assets/image.png" alt="Description"></ion-img>
+ <img src="/assets/image.png" alt="Description" loading="lazy" decoding="async" />

Events

The native <img> element does not emit Ionic's custom events. Use the standard DOM events instead:

ion-img eventNative <img> replacement
ionImgWillLoadNo native equivalent. This fired when the image scrolled into view and lazy loading began. With native loading="lazy" the browser handles this internally. To know when an image is about to enter the viewport, use an IntersectionObserver.
ionImgDidLoadload¹
ionErrorerror¹

¹ Native load and error do not bubble, while the Ionic events did. If you used event delegation (one listener on a parent), listen on each <img> instead, or use the capture phase: parent.addEventListener('load', handler, true).

Styling

ion-img exposed an image CSS shadow part for styling the inner image. With a native <img>, style the element directly instead:

- ion-img::part(image) {
- border-radius: 8px;
- }
+ img {
+ border-radius: 8px;
+ }

Input

autocorrect Property Type Changed to Boolean

The autocorrect property on ion-input is now a boolean (default false) instead of 'on' | 'off'. Because the attribute coerces to true for any value other than the string "false", autocorrect="off" now enables autocorrect.

  • Remove the attribute to keep autocorrect disabled (the default).
  • Use a property binding to enable it: [autocorrect]="true" (Angular), autocorrect={true} (React), or :autocorrect="true" (Vue).

Internal DOM Structure Changes

New wrapper elements have been added to the component's internal DOM structure to support floating labels with slotted start and end content. Additionally, the structure of the component has been reorganized, with some elements now grouped differently than before. This may introduce breaking changes for developers who rely on the component's internal DOM structure or apply custom styling to internal elements.

The following internal wrapper elements have been added:

  • Added: <div class="input-start"> wrapper for the start slot
  • Added: <div class="input-control"> wrapper for the label and native control
  • Added: <div class="input-end"> wrapper for the end slot and clear button

While the public API has not changed, selectors or style overrides targeting the previous markup may need to be updated to reference the new wrapper elements and their organization. If you have custom CSS targeting the internal structure of input, update your selectors to account for these structural changes.

Legacy Picker

The ion-picker-legacy and ion-picker-legacy-column components have been removed.

  • Usages such as ion-picker-legacy or IonPickerLegacy should be changed to ion-picker and IonPicker, respectively. Review the Picker in Modal documentation for more information.
  • Remove any usages of pickerController. If using React, remove any usages of the useIonPicker hook. These controller-based APIs have been removed. Use the Picker component instead.
  • Remove any usages of the PickerOptions, PickerButton, PickerColumn, and PickerColumnOption type exports. These types were associated with the legacy picker and have been removed.

handleBehavior Default Changed

The handleBehavior property on ion-modal now defaults to "cycle" instead of "none". For sheet modals that display a handle, this means the handle is now focusable and activating it (by click, keyboard, or screen reader) cycles the sheet through its available breakpoints. This matches the native iOS sheet behavior and keeps sheet modals operable for assistive technology users by default.

Sheet modals that relied on the handle being inert should set handleBehavior="none" to restore the previous behavior:

<ion-modal handle-behavior="none"></ion-modal>

Router Integration Removed

ion-nav no longer integrates with ion-router. It is now a standalone imperative stack navigation component, driven only through its own API (root, push, pop, setRoot, and so on) and ion-nav-link.

This only affects apps that placed an ion-nav inside an ion-router (vanilla JavaScript projects) and relied on the router to drive it. If you use ion-nav on its own for local, in-page stack navigation, no changes are required.

The following behaviors have been removed:

  • The router no longer discovers or drives an ion-nav. Placing an ion-nav inside an ion-router no longer turns it into a routed outlet.
  • Navigating an ion-nav (via push, pop, ion-nav-link, or the swipe-to-go-back gesture) no longer updates the URL, and the router's navigation guards no longer run for ion-nav transitions.
  • The setRouteId() and getRouteId() methods and the updateURL nav option have been removed. These existed only for the router integration.

If you relied on ion-nav to update the URL, use ion-router-outlet for URL-based routing instead. Keep the ion-route definitions and swap the outlet element:

  <ion-router>
<ion-route url="/" component="page-one"></ion-route>
<ion-route url="/page-two" component="page-two"></ion-route>
</ion-router>

- <ion-nav></ion-nav>
+ <ion-router-outlet></ion-router-outlet>

An ion-nav can still be nested inside a routed page for local, URL-less stack navigation. It manages its own stack via root and ion-nav-link, and the URL never changes as you push and pop. For a complete, working example, refer to Using ion-nav within a Routed Page.

Router Outlet

ion-router-outlet now exposes a swipeGesture property that controls the swipe-to-go-back gesture per outlet. This property defaults to true in "ios" mode and false in "md" mode.

swipeBackEnabled Config Behavior Change

In React and Vue, the swipeBackEnabled config option is now read once when the outlet mounts. Apps that dynamically toggle this config value at runtime should migrate to the swipeGesture property instead.

React:

- setupIonicReact({ swipeBackEnabled: someCondition });
+ <IonRouterOutlet swipeGesture={someCondition} />

Vue:

- createApp(App).use(IonicVue, { swipeBackEnabled: someCondition })
+ <ion-router-outlet :swipe-gesture="someCondition" />

Disabling Swipe-to-Go-Back

To disable the gesture on a specific outlet, set swipeGesture to false:

<IonRouterOutlet swipeGesture={false} />

The swipeBackEnabled config option is still respected as the initial default and does not need to change for apps that set it once at startup.

autocorrect Property Type Changed to Boolean

The autocorrect property on ion-searchbar is now a boolean (default false) instead of 'on' | 'off'. Because the attribute coerces to true for any value other than the string "false", autocorrect="off" now enables autocorrect.

  • Remove the attribute to keep autocorrect disabled (the default).
  • Use a property binding to enable it: [autocorrect]="true" (Angular), autocorrect={true} (React), or :autocorrect="true" (Vue).

Select

ionChange Only Fires When the Value Changes

The ionChange event on ion-select now only fires when the selected value actually changes. Previously, the alert and action-sheet interfaces emitted ionChange every time the overlay was confirmed, even when the user chose the option that was already selected. This aligns the alert and action-sheet interfaces with the existing behavior of the popover and modal interfaces, and with the documented contract of ionChange.

Apps that relied on ionChange firing on every confirmation (for example, to detect overlay dismissal without a value change) should listen for ionDismiss instead, or use the didDismiss event on the underlying alert or action sheet.

Action Sheet Interface selected Role Removed

When using interface="action-sheet", ion-select no longer assigns the selected role to the action sheet button for the currently selected option. This aligns the action-sheet interface with the alert, popover, and modal interfaces, none of which assign this role. This does not change the selected option's styling.

Previously, the selected role was assigned only to the option matching the select's current value. Because the dismiss role mirrors the tapped button, this surfaced in just one case: re-selecting the already-selected option dismissed the action sheet with role: "selected" in ionActionSheetDidDismiss. Tapping any other option changed the value and dismissed with role: "". Now that the role is no longer assigned, both cases dismiss with role: undefined. Apps that inspected this role to detect that a value was chosen, such as reading role from the underlying action sheet's onDidDismiss result, should listen for ion-select's ionChange event instead, which emits the selected value when the selection changes.

Internal DOM Structure Changes

The component's internal DOM structure has been restructured to support floating labels with slotted start and end content. Additionally, the structure of the component has been reorganized, with some elements now grouped differently than before. The inner wrapper element has been removed, and its content has been split across separate wrapper elements for the start slot, control, and end slot. This may introduce breaking changes for developers who rely on the component's internal DOM structure or apply custom styling to internal elements.

Developers who previously styled ion-select::part(inner) should migrate to targeting the updated component structure using the following CSS parts instead:

  • ion-select::part(start) - Target the start slot wrapper
  • ion-select::part(control) - Target the control wrapper containing the label and native select. When the label is not floating or stacked, this part also contains the dropdown icon.
  • ion-select::part(end) - Target the end slot wrapper. When the label is floating or stacked, this part also contains the dropdown icon.

Textarea

Internal DOM Structure Changes

The internal DOM structure has been modified to support floating labels with slotted start and end content. Additionally, the structure of the component has been reorganized, with some elements now grouped differently than before. This may introduce breaking changes for developers who rely on the component's internal DOM structure or apply custom styling to internal elements.

The following internal elements have been modified:

  • Removed: <div class="textarea-wrapper-inner">
  • Renamed: <div class="start-slot-wrapper"> is now <div class="textarea-start">
  • Added: <div class="textarea-control"> wrapper for the label and native control
  • Renamed: <div class="end-slot-wrapper"> is now <div class="textarea-end">

While the public API has not changed, selectors or style overrides targeting the previous markup will need to be updated to reference the new element names and their organization. If you have custom CSS targeting the internal structure of textarea, update your selectors to account for these structural changes.

Minimum Height Change

The minimum height of textarea in Material Design (md mode) has been increased from 56px to 72px. This change ensures consistent heights across textareas regardless of the fill property or labelPlacement, providing a more uniform and predictable user experience. If you were relying on textareas being 56px tall or had custom CSS based on that value, you will need to either update your styles to accommodate the new 72px height or override it back to 56px if needed.

Need Help Upgrading?

Be sure to look at the Ionic 9 Breaking Changes Guide for the complete list of breaking changes. This upgrade guide only covers changes that require action from developers.

If you need help upgrading, please post a thread on the Ionic Forum.