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:
- Never import a heavy library in a shared file. One
import { Chart } from 'chart.js'in ashared/utils.tsdrags the whole library into the initial chunk. Import it inside the lazy page, or behindawait import()at the point of use. - Prefer
PreloadAllModuleson 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. - Defer non-critical widgets in the template.
@deferwith anon viewporttrigger 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-timezone→Intl.DateTimeFormatordate-fnswith named imports. Typically 60–80 KB gzipped.lodash→lodash-eswith named imports, or native equivalents.- Deep
rxjs/internal/...imports → top-levelrxjs/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:
ng updateto the current Angular and Ionic minor. Ship it.- Application builder +
angular.jsonfixes + CI/cap syncverification. Ship it. - Standalone schematic modes 1–3, one commit each, with the Playwright smoke test in place. Ship it.
- Route-level
loadComponentsplitting, page group by page group. Ship each. - 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.