+1 (415) 843-4662

Upgrading to Capacitor 8: Swift Package Manager, the Android toolchain and plugin triage

Every Capacitor major release since 6 has moved the native projects a little further away from the CocoaPods-and-old-Gradle world most Ionic apps were born in. Capacitor 7 made Swift Package Manager (SPM) a supported option on iOS; Capacitor 8 makes it the default path, raises the Android toolchain (AGP 8.x / Gradle 8.x+, compile and target SDK 36, JDK 21) and drops a pile of long-deprecated APIs. If your app still builds with CocoaPods and an android/ folder that has not been touched since Ionic 6, this is the upgrade that unblocks the next two years of OS releases.

This tutorial is the sequence we run on client apps: audit, branch, iOS to SPM, Android toolchain, plugin triage, verify. Budget a day for a small app and a week for one with custom native code.

0. Audit before you touch anything

Run these first and paste the output into the migration ticket:

npx cap doctor
npm ls @capacitor/core @capacitor/ios @capacitor/android
npm outdated | grep -E 'capacitor|ionic'

cap doctor tells you the installed Capacitor versions per platform and whether the native projects are in sync. The dependency list matters more: every Capacitor plugin you depend on must ship a version built for the new major. Official @capacitor/* plugins track the majors within days. Community plugins are the risk — check each repo for a release tagged for the new major, and note the ones that have gone quiet. A plugin that has had no commits in eighteen months is a decision, not a dependency.

Also confirm your CI images. SPM builds and AGP 8.x need Xcode 16+ and JDK 21; a self-hosted runner pinned to JDK 17 will fail the first Android build after the upgrade.

1. Branch, and upgrade the JavaScript side first

git checkout -b chore/capacitor-8
npm i @capacitor/core@8 @capacitor/cli@8
npm i @capacitor/ios@8 @capacitor/android@8
npm i @capacitor/app@8 @capacitor/haptics@8 @capacitor/keyboard@8   # etc.
npx cap sync

Do not run the platform migration yet. Get npm run build green and the app running in the browser (ionic serve) with the new core. Type errors here are cheap; the same errors discovered after the native projects have been rewritten are not.

2. iOS: move to Swift Package Manager

The CLI ships a migration command that rewrites the Xcode project to consume Capacitor and your plugins as Swift packages instead of Pods:

npx cap migrate            # runs the guided migration
# or, iOS only:
npx cap update ios

What it does, and what you should verify by hand afterwards:

  1. Creates ios/App/CapApp-SPM — a local Swift package that lists Capacitor and each plugin as a dependency. Open CapApp-SPM/Package.swift and check that every plugin in your package.json appears. Plugins that have not published SPM support will be missing; that is your triage list for step 4.
  2. Removes the CocoaPods integration. Delete the leftovers once the build is green:
    cd ios/App && rm -rf Pods Podfile Podfile.lock App.xcworkspace
    
    From here you open App.xcodeproj, not the workspace. Update any CI or fastlane step that still passes -workspace App.xcworkspace — this is the single most common breakage.
  3. Bumps the deployment target. Capacitor 8 requires iOS 15+. If your Podfile pinned 13 or 14, check your analytics before shrugging: anything below ~0.3% of sessions is normally fine to drop, but it is a product decision, not a build decision.

Then build:

npx cap sync ios
npx cap open ios

If Xcode complains it cannot resolve packages, reset the cache with File → Packages → Reset Package Caches, and make sure the checked-in Package.resolved is committed — pinning it is what makes CI builds reproducible.

Keeping a native pod you still need

If you have a native SDK that is only distributed as a pod (some analytics and payment SDKs still are), you can keep CocoaPods alongside SPM for that one dependency: leave a minimal Podfile targeting App with just that pod, keep using the workspace, and let Capacitor itself come from SPM. It works, but treat it as temporary and re-check the vendor's SPM support each quarter.

3. Android: toolchain, not code

Most of the Android work is configuration:

// android/build.gradle
classpath 'com.android.tools.build:gradle:8.7.2'

// android/variables.gradle
ext {
    minSdkVersion = 23
    compileSdkVersion = 36
    targetSdkVersion = 36
    androidxActivityVersion = '1.9.2'
    // ...
}
# android/gradle/wrapper/gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

Three things bite here:

  • JDK 21. Set it in Android Studio (Settings → Build Tools → Gradle) and in CI (actions/setup-java with java-version: 21).
  • Namespace, not package. AGP 8 requires namespace in android/app/build.gradle and forbids package in AndroidManifest.xml. Community plugins that still carry package will fail the build — that is the second half of your triage list.
  • Target SDK 36 pulls in Android 16 behaviour: edge-to-edge is enforced and predictive back is on by default. We covered the layout side of that in edge-to-edge and safe areas; do that work in the same branch, because the upgrade is what exposes it.

4. Plugin triage

For each plugin the migration flagged, you have four options, in order of preference:

  1. Upgrade to a release that supports the new major.
  2. Replace it with an official @capacitor/* equivalent — several community plugins (filesystem, preferences, geolocation wrappers) have official counterparts now.
  3. Fork and patch. For an SPM-less plugin this is often a twenty-line change: add a Package.swift, move sources to Sources/<PluginName>/, and point your CapApp-SPM/Package.swift at the fork with .package(path:) or a Git URL. For an Android package attribute, move it to namespace.
  4. Absorb it. If the plugin wraps thirty lines of native code, write your own — see writing your own Capacitor plugin. One fewer unmaintained dependency is worth the afternoon.

5. Verify on device, not just in the simulator

The upgrade changes how the app is linked, so re-test the things that depend on native wiring:

  • cold start and splash screen dismissal,
  • push notification registration and a delivered notification tapped from cold start,
  • deep links into a route that requires auth,
  • camera, filesystem and any BLE/NFC hardware paths,
  • an App Store Connect / Play Console upload from CI, not just a local build.
npx cap run ios --target <device-id>
npx cap run android --target <device-id>
./gradlew :app:bundleRelease        # from android/, catches release-only issues

Release builds catch what debug builds hide: R8 rules from a removed plugin, missing entitlements after the workspace change, and signing steps that still reference the old workspace path.

A migration checklist you can paste into a ticket

  • cap doctor + plugin inventory captured
  • CI images on Xcode 16+ / JDK 21
  • JS layer upgraded and browser build green
  • npx cap migrate run; CapApp-SPM/Package.swift reviewed
  • Pods removed; fastlane/CI switched from workspace to project
  • Package.resolved committed
  • AGP / Gradle / SDK 36 / JDK 21 set; namespace present everywhere
  • Every flagged plugin upgraded, replaced, forked or absorbed
  • Device smoke test: push, deep links, camera, hardware
  • Signed release build uploaded from CI

Done in one branch, this is a boring upgrade. Left for another year, it becomes a rewrite, because the next OS release will force the toolchain move anyway and you will be doing it under a store deadline.

If you would rather not spend your team's sprint on Gradle files, this is exactly the kind of work we take end to end — see legacy Ionic app modernization or talk to us about an upgrade audit.