Tutorial9 min read

From PWA to native app: ship your PWA to the stores with Capacitor

Your PWA already runs everywhere except the App Store. Capacitor wraps it as a native iOS and Android app you can submit, and OtaKit keeps it updated over the air like a PWA.

A PWA already runs everywhere — except where a lot of your users look for apps: the App Store and Google Play. Capacitor closes that gap. It wraps your PWA as a real native app you can submit to both stores, gives it access to device APIs a browser can't reach, and keeps your single web codebase intact.

This guide takes an existing PWA to installable native builds and adds OtaKit so the app updates over the air — the same “always current” feel a PWA has on the web.

Mental model: your PWA stays exactly what it is on the web. Capacitor adds a native wrapper for the stores; OtaKit handles updates inside that wrapper.

1. Point Capacitor at your build output

A PWA is just a web app with a manifest and a service worker, so there's nothing special to change — identify the folder your build produces (dist, build, or out) and make sure assets use relative paths.

npm install @capacitor/core
npm install -D @capacitor/cli

npx cap init "My App" com.example.myapp --web-dir dist

npm install @capacitor/ios @capacitor/android

2. Rethink the service worker

This is the one PWA-specific gotcha. On the web, your service worker caches assets and handles offline. Inside a Capacitor app, the web layer already loads from local files, and a service worker's caching can fight with native updates — you can end up serving a stale cache after an OTA update lands.

The clean approach: let Capacitor serve the bundle from disk, and let OtaKit handle updates instead of the service worker's cache. Either disable the service worker on native, or scope its caching so it doesn't shadow the bundle. Detect the platform to branch:

import { Capacitor } from "@capacitor/core";

if (!Capacitor.isNativePlatform() && "serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js"); // web only
}

3. Build the native apps

npm run build
npx cap add ios
npx cap add android
npx cap sync

Run with npx cap open ios / npx cap open android. Your PWA now runs as a native app, with the same UI it always had.

4. Add live updates with OtaKit

This is what restores the PWA's best trait — being always up to date — in the native wrapper:

npm install @otakit/capacitor-updater
npx cap sync
// capacitor.config.ts
const config: CapacitorConfig = {
  appId: "com.example.myapp",
  appName: "My App",
  webDir: "dist",
  plugins: {
    OtaKit: { appId: "YOUR_OTAKIT_APP_ID" },
  },
};
import { Capacitor } from "@capacitor/core";
import { OtaKit } from "@otakit/capacitor-updater";

if (Capacitor.isNativePlatform()) OtaKit.notifyAppReady();
npm install -g @otakit/cli
otakit login

npm run build
otakit upload --release

Now a change you deploy to your PWA on the web can ship to the installed native app the same day with otakit upload --release — no store review for web-layer changes. Native changes (new plugins, permissions) still go through the store.

Best of both worlds: the reach and updateability of a PWA, plus a store presence and native device access. See why OTA is compliant.

Where to go next

The setup guide covers the full flow. Then read how OTA works and background vs foreground update UX.

Related docs