Back to blog
Jan 06, 2026
9 min read

Upgrading an Angular Ionic App from v18 to v21: A Step-by-Step Guide

A practical guide to upgrading Angular across multiple major versions (18 → 19 → 20 → 21) in an Ionic app, covering ESLint migration, TypeScript strict mode issues, and the WebNative VS Code extension.

Angular Migration

Upgrading Angular across multiple major versions can be daunting, especially when you’re dealing with an Ionic app that has its own set of dependencies. In this guide, I’ll walk you through the process of upgrading from Angular 18 to 21, covering the errors I encountered and how I solved them.

I used the WebNative VS Code extension to automate much of this process. WebNative VS Code extension

Throughout this guide, I’ll show you the commands it runs under the hood so you can run them manually if you prefer.

WebNative sidebar showing migration options WebNative extension sidebar with “Migrate to Angular 19” recommendation

The App

This guide is based on my experience upgrading Stream Chat, an Ionic Angular chat application featuring group messaging, photo grids, file attachments, message reactions, and a long-press context menu (similar to Telegram or WhatsApp).

Important context: My app is relatively small with around 45 dependencies, mostly within the Angular/Ionic ecosystem. If your app has many third-party dependencies outside this ecosystem, you may encounter additional compatibility issues that require more careful handling.

Prerequisites

  • Node.js (LTS version recommended)
  • An existing Ionic Angular app on v18
  • pnpm (or npm/yarn - adjust commands accordingly)

The Upgrade Path

Angular recommends upgrading one major version at a time. So the path is:

18 → 19 → 20 → 21

The official Angular Update Guide is your friend here. Select your current and target versions to get customized instructions.

Testing is critical: After each version upgrade, thoroughly test your app before proceeding to the next version. Run your build, run your tests, and manually test key functionality. Catching issues early makes debugging much easier than trying to figure out which of three upgrades broke something.


Step 1: Angular 18 to 19

When you click “Migrate to Angular 19” in WebNative, it prompts you to confirm:

WebNative migration prompt WebNative prompts to confirm migration and reminds you to commit your code first

The Problem: Peer Dependency Conflicts

If you run the update without flags, you’ll hit peer dependency errors:

Peer dependency error The @angular-eslint/schematics package has incompatible peer dependencies

What WebNative Runs Under the Hood

WebNative runs the following command:

pnpm exec ng update @angular/cli@19 @angular/core@19 --allow-dirty --force

The flags:

  • --allow-dirty - Allows the update even if you have uncommitted changes. Caution: It’s always best to commit your changes before running an update, even when using this flag.
  • --force - Proceeds despite peer dependency conflicts. Caution: Use this with care. It’s useful for overcoming issues like the ESLint package conflicts that you plan to resolve immediately after, but it can force through genuinely incompatible packages.

After the core update, WebNative updates these packages separately if they exist in your project:

pnpm add @angular/cdk@19 --save-exact --force
pnpm add @angular-eslint/builder@19 @angular-eslint/eslint-plugin@19 @angular-eslint/eslint-plugin-template@19 @angular-eslint/schematics@19 @angular-eslint/template-parser@19 --save-exact --force

ESLint: Uninstall and Start Fresh

WebNative recommended uninstalling ESLint entirely. Here’s why:

  1. ESLint 8.x reached end-of-life on October 5, 2024 (source)
  2. ESLint 9 uses a new “flat config” format (eslint.config.js) - the old .eslintrc format is deprecated (migration guide)
  3. Peer dependency conflicts - ESLint 8.x has conflicts with newer angular-eslint packages

Rather than wrestle with complex migration, WebNative suggests removing ESLint and re-adding it fresh:

pnpm remove eslint

After your Angular upgrade is complete, you can re-add ESLint with the new flat config format:

ng add @angular-eslint/schematics

This sets up ESLint 9 with the modern eslint.config.js configuration file that Angular 19+ expects. Note that this command will likely prompt you for some configuration choices to tailor the setup to your project.

Optional Migrations

Angular CLI offers optional migrations like use-application-builder (the new esbuild-based build system). How you get these depends on how you run the update:

If using WebNative: The extension runs ng update in the Output panel (non-interactive), so optional migrations are skipped. You can run them manually afterward:

ng update @angular/cli --name use-application-builder

If running manually in terminal: You’ll see interactive prompts to select migrations:

Migration prompts Select use-application-builder to migrate to the new esbuild-based build system

Migration prompts Select provide-initializer to migrate to the new provideAppInitializer function

The key optional migrations for Angular 19 are:

  1. use-application-builder - Migrates to the new esbuild-based build system (recommended for significantly faster build times).
  2. provide-initializer - Replaces the legacy APP_INITIALIZER multi-provider token with the new, tree-shakable provideAppInitializer function for running initialization logic.

What Changed in package.json

package.json diff after Angular 19 update All @angular packages updated from 18.x to 19.x

Important: Output Path Change

The new build system changes the output directory structure:

Before: www/
After:  www/browser/

You may need to update your deployment pipeline, or set outputPath.browser to "" in angular.json to maintain the previous behavior.

IDE Quirks

After the update, you might see false errors in your components (especially around standalone components). The migration removes the now-default standalone: true property:

standalone: true removal diff The migration removes standalone: true since it’s now the default in Angular 19+

If you see errors after this change:

  1. Restart the Angular Language Service extension
  1. If that fails, restart your IDE

Test Before Proceeding

Before moving to Angular 20, make sure to:

pnpm run build
pnpm run test
pnpm start  # Manual testing

Fix any issues before proceeding to the next version.


Step 2: Angular 19 to 20

pnpm exec ng update @angular/cli@20 @angular/core@20 --allow-dirty --force

This upgrade went smoother, but I hit a build error with Ionic:

Error: node_modules/.pnpm/@ionic+core@8.7.16/node_modules/@ionic/core/dist/types/components.d.ts:4330:15
- error TS2320: Interface 'HTMLIonInputElement' cannot simultaneously extend types 'IonInput' and 'HTMLStencilElement'.
Named property 'autocorrect' of types 'IonInput' and 'HTMLStencilElement' are not identical.

Understanding the Problem

This is a type conflict between Ionic’s Stencil-generated types and TypeScript 5.9’s stricter type checking. The autocorrect property is defined differently in two parent interfaces.

The Solution

Add skipLibCheck to your tsconfig.json:

{
  "compilerOptions": {
    "skipLibCheck": true
  }
}

This tells TypeScript to skip type checking of declaration files (.d.ts), which is safe for dependency type conflicts you can’t control.

Reference: GitHub Issue #30650

Service Worker Configuration Change

If you’re using PWA features, you might encounter:

Data path "" must NOT have additional properties(ngswConfigPath)

The fix is to update angular.json:

angular.json serviceWorker config diff Replace the separate serviceWorker and ngswConfigPath properties with a single serviceWorker path

Replace:

"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json"

With:

"serviceWorker": "ngsw-config.json"

Reference: Ionic Forum Discussion

Verify and Test

Again, test thoroughly before moving to Angular 21:

pnpm run build
pnpm run test
pnpm start

Step 3: Angular 20 to 21

pnpm exec ng update @angular/cli@21 @angular/core@21 --allow-dirty --force

This is where TypeScript’s stricter type checking really showed up.

The TouchEvent vs MouseEvent Error

I had a custom LongPressDirective that handles both touch and mouse events:

@HostListener('touchstart', ['$event'])
@HostListener('mousedown', ['$event'])
onPress(event: MouseEvent) {
  // ...
}

After the upgrade, TypeScript threw:

Argument of type 'TouchEvent' is not assignable to parameter of type 'MouseEvent'.
Type 'TouchEvent' is missing the following properties from type 'MouseEvent':
button, buttons, clientX, clientY, and 15 more.

Why This Error Appeared Now

This error was always a latent bug, but it was hidden by older TypeScript’s more lenient checking. Here’s what changed:

AspectAngular 20 (TS 5.5)Angular 21 (TS 5.9)
TypeScript Version5.5.45.9.3
Targetes2015ES2022
DOM Type DefinitionsLess strictMore precise
Type InferenceLenientStricter

The newer TypeScript version has:

  • Better DOM type definitions with more accurate event types
  • Stricter enforcement of type mismatches in event handlers
  • Full ES2022 DOM types (without explicit lib override)

The Fix

Change the parameter type to accept both event types:

@HostListener('touchstart', ['$event'])
@HostListener('mousedown', ['$event'])
onPress(event: MouseEvent | TouchEvent) {
  // ...
}

@HostListener('touchend', ['$event'])
@HostListener('touchcancel', ['$event'])
@HostListener('mouseup', ['$event'])
@HostListener('mouseleave', ['$event'])
onRelease(event: MouseEvent | TouchEvent) {
  // ...
}

This is actually a good thing - TypeScript caught a real type safety issue at compile time rather than letting it slip through to runtime.


Post-Upgrade Checklist

After completing all upgrades, do a final comprehensive test:

  1. Run the build

    pnpm run build
  2. Run your tests

    pnpm run test
  3. Run linting (if you have ESLint configured)

    pnpm run lint
  4. Manual testing - Test key user flows, especially:

    • Touch interactions (if applicable)
    • Any features using third-party libraries
    • PWA functionality (if applicable)
  5. Test on device/emulator - Some issues only appear at runtime on actual devices


Summary

Version JumpKey ChallengesSolutions
18 → 19ESLint peer deps, build system migration--allow-dirty --force, run optional migrations
19 → 20Ionic type conflicts, service worker configskipLibCheck, update angular.json
20 → 21Stricter TypeScript, event type mismatchesFix union types (MouseEvent | TouchEvent)

The upgrade process exposed some latent type issues in my codebase, which is ultimately beneficial. Stricter type checking catches bugs earlier and makes the codebase more maintainable.

Remember: My app had ~45 dependencies, mostly within the Angular/Ionic ecosystem. If your app has more complex dependencies, budget extra time for resolving compatibility issues.

Useful Resources