+1 (415) 843-4662

Migrating Ionic Angular from Webpack to esbuild, standalone components and lazy routes

Most of the Ionic Angular apps we are asked to rescue in 2026 still build with the old Webpack-based @angular-devkit/build-angular:browser builder, still declare every page in an NgModule, and still take two to four minutes for a cold ionic build. Angular's application builder (esbuild + Vite dev server) routinely cuts that to a tenth, gives you a dev server that reloads in under a second, and is a prerequisite for most of the newer Angular features your team wants — standalone components, @defer-style lazy loading of heavy routes, and zoneless change detection.

This tutorial is the migration we actually run on client codebases: move to the application builder, convert modules to standalone, split the bundle along routes, and keep Capacitor happy on the way through. It assumes Ionic 8/9 with Angular 19+ and Capacitor 7/8.

0. Baseline your build first

Do not start refactoring until you can prove an improvement. Record three numbers and keep them in the PR description:

# cold production build time
rm -rf www .angular && time npx ng build --configuration production

# what is actually in the bundle
npx ng build --configuration production --stats-json
npx source-map-explorer 'www/**/*.js'

The stats file is the useful artefact. On a typical legacy Ionic app the top of the list is moment, all of @ionic/angular pulled in eagerly, an entire charting library on a screen 4% of users open, and three copies of rxjs operators imported from deep paths.

1. Switch to the application builder

The migration is a schematic, and on a reasonably clean project it is a one-liner:

npx ng update @angular/cli @angular/core
npx ng generate @angular/core:use-application-builder

That rewrites angular.json to use @angular-devkit/build-angular:application and updates the browser/polyfills/styles keys. What it will not fix, and what breaks on Ionic projects in practice:

ngsw/service worker and www output path. Capacitor copies from webDir. The application builder emits a browser bundle into a browser/ subfolder, so www/index.html becomes www/browser/index.html and npx cap sync silently ships an empty app. Fix it in angular.json by pointing the output at a base plus explicit browser dir:

"options": {
  "outputPath": { "base": "www", "browser": "" },
  "index": "src/index.html",
  "browser": "src/main.ts",
  "polyfills": ["zone.js"],
  "tsConfig": "tsconfig.app.json"
}

Setting "browser": "" keeps the old flat www/ layout, so capacitor.config.ts with webDir: 'www' keeps working and no CI script has to change.

CommonJS dependencies. esbuild is far stricter than Webpack. Any Cordova plugin shim or older SDK that ships only CJS now warns or fails. List them explicitly rather than ignoring the warnings globally:

"allowedCommonJsDependencies": ["cordova-plugin-file", "sockjs-client"]

Sass and Ionic variables. @import of Ionic's Sass partials still works but is deprecated; move to @use and add the include path:

"stylePreprocessorOptions": { "includePaths": ["node_modules", "src/theme"] }
// src/global.scss
@use "@ionic/angular/css/core.css";
@use "variables" as vars;

Dev server proxying. The Vite-backed dev server reads the same proxy.conf.json, but on device you are hitting the LAN host, not localhost. Keep the Capacitor live-reload story explicit:

npx ng serve --host 0.0.0.0 --port 8100
npx cap run ios --live-reload --host 192.168.1.20 --port 8100

Add server.url only via a dev-only config file so a live-reload URL can never reach a release build.

2. Convert modules to standalone

Standalone conversion is also a schematic, and it is safe to run in stages:

npx ng generate @angular/core:standalone   # mode 1: convert declarations
npx ng generate @angular/core:standalone   # mode 2: remove unnecessary NgModules
npx ng generate @angular/core:standalone   # mode 3: bootstrap with standalone APIs

Run one mode per commit and run your test suite in between. Two Ionic-specific gotchas:

IonicModule.forRoot() moves from AppModule to provideIonicAngular():

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideIonicAngular, IonicRouteStrategy } from '@ionic/angular/standalone';
import { RouteReuseStrategy } from '@angular/router';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';

bootstrapApplication(AppComponent, {
  providers: [
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
    provideIonicAngular({ mode: 'ios', useSetInputAPI: true }),
    provideRouter(routes, withPreloading(PreloadAllModules)),
  ],
});

And once you import from @ionic/angular/standalone, you must import each component and register each icon yourself. That is the whole point — it is what removes ~200 KB of unused components from the main chunk — but a missed import renders as an empty custom element with no console error in production builds:

import { Component } from '@angular/core';
import { IonHeader, IonToolbar, IonTitle, IonContent, IonIcon, IonButton } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { cameraOutline, trashOutline } from 'ionicons/icons';

@Component({
  selector: 'app-inspection',
  templateUrl: './inspection.page.html',
  imports: [IonHeader, IonToolbar, IonTitle, IonContent, IonIcon, IonButton],
})
export class InspectionPage {
  constructor() {
    addIcons({ cameraOutline, trashOutline });
  }
}

Catch the missed ones in CI rather than in the store. A cheap grep-based check that fails the build when a template uses an ion- tag the component does not import is worth the twenty lines it takes to write; a Playwright smoke test that visits every route and asserts no custom element has zero height is better. (Our earlier post on end-to-end testing for Ionic and Capacitor apps has the harness for that.)

3. Make routes actually lazy

With NgModules gone, lazy loading moves to loadComponent:

// app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', redirectTo: 'tabs/home', pathMatch: 'full' },
  {
    path: 'tabs',
    loadComponent: () => import('./tabs/tabs.page').then(m => m.TabsPage),
    children: [
      {
        path: 'home',
        loadComponent: () => import('./home/home.page').then(m => m.HomePage),
      },
      {
        path: 'reports',
        // the charting library only ever enters this chunk
        loadComponent: () => import('./reports/reports.page').then(m => m.ReportsPage),
      },
    ],
  },
];

Three rules we apply on every engagement:

  1. Never import a heavy library in a shared file. One import { Chart } from 'chart.js' in a shared/utils.ts drags the whole library into the initial chunk. Import it inside the lazy page, or behind await import() at the point of use.
  2. Prefer PreloadAllModules on mobile, not on web. On a phone the bundle is already on disk after install; preloading costs nothing and removes the route-transition stall. On the PWA build, drop it or use a custom preloader.
  3. Defer non-critical widgets in the template. @defer with an on viewport trigger is the cheapest win on long dashboard pages:
@defer (on viewport) {
  <app-usage-chart [data]="usage()" />
} @placeholder {
  <ion-skeleton-text [animated]="true" style="height: 220px"></ion-skeleton-text>
}

4. Kill the last CommonJS and polyfill weight

While the stats file is open, do the three replacements that pay for themselves on nearly every legacy Ionic app:

  • moment / moment-timezoneIntl.DateTimeFormat or date-fns with named imports. Typically 60–80 KB gzipped.
  • lodashlodash-es with named imports, or native equivalents.
  • Deep rxjs/internal/... imports → top-level rxjs / rxjs/operators.

Then drop the polyfills you no longer need. Ionic 8/9 targets evergreen WebViews; if your browserslist still names IE or Android 5, esbuild is down-levelling code for browsers you do not ship to. A sane baseline for a Capacitor app:

last 2 Chrome versions
last 2 Safari versions
iOS >= 15
Android >= 8

Check what that actually implies before you commit it — if the app also ships as a PWA for a corporate fleet, ask which WebView the fleet is on.

5. Verify on device, not just in the browser

Bundle graphs lie about perceived speed. Re-measure the three baseline numbers, then measure cold start on real hardware:

# Android cold start, averaged over 10 runs
adb shell am force-stop com.example.app
adb shell am start-activity -W -n com.example.app/.MainActivity | grep TotalTime

On the last four migrations we ran, the shape of the result was consistent: production build time down 70–85%, dev reload from 6–10 s to under 1 s, initial JS down 30–45%, and Android cold start improved by 300–600 ms — most of it from routes that stopped being eagerly bundled rather than from esbuild itself.

Rollout order that avoids a two-week freeze

Do not do this as one branch. The order that has worked for us:

  1. ng update to the current Angular and Ionic minor. Ship it.
  2. Application builder + angular.json fixes + CI/cap sync verification. Ship it.
  3. Standalone schematic modes 1–3, one commit each, with the Playwright smoke test in place. Ship it.
  4. Route-level loadComponent splitting, page group by page group. Ship each.
  5. Dependency replacements and browserslist trimming last, when the stats file makes the case for each one.

Every step above is independently shippable and independently revertible. That matters more than the total speedup: a migration that has to land all at once is a migration that sits on a branch for a quarter and then gets abandoned.


Sitting on an Ionic Angular codebase that still builds with Webpack and NgModules, and unsure how much of this you can do without stalling feature work? We do this migration as a fixed-scope engagement — baseline, staged rollout, and a CI harness so it does not regress. Get in touch and tell us your Angular, Ionic and Capacitor versions.