Skip to content

Publishing to App Stores

You can publish a Progressive Web App to app stores without rewriting it, but each store accepts something different: Google Play and the Meta Horizon Store take an Android package that opens your site in a Trusted Web Activity, the Microsoft Store takes an MSIX package generated from your manifest, and Apple's App Store accepts only a native app, so a PWA has to ship inside a WKWebView wrapper and pass review on its own merits. Store distribution adds discoverability, store search, ratings and in some cases store billing, at the price of developer accounts, review cycles, policy compliance and extra packaging work. This page explains what each store requires in September 2026, step by step, with the verification files, build commands, billing code and policy details that decide whether a submission ships.

Key takeaways

  • Google Play: package the PWA as a Trusted Web Activity with Bubblewrap or PWABuilder, upload an Android App Bundle, and prove domain ownership with /.well-known/assetlinks.json containing the Play App Signing SHA-256 fingerprint. From August 31, 2026, new apps and updates must target Android 16 (API level 36); Bubblewrap 1.25.0 (July 2026) targets it.
  • Microsoft Store: reserve a name in Partner Center, generate .msixbundle and .classic.appxbundle packages with PWABuilder, and submit. Registration is free for individuals (September 2025) and companies (May 2026). Web content updates need no resubmission; manifest changes do.
  • Apple App Store: there's no PWA submission path. You ship a native app that hosts your site in WKWebView (PWABuilder's community-maintained template, Capacitor, or your own), and guideline 4.2 rejects apps that aren't "elevate[d] beyond a repackaged website".
  • Other stores: the Meta Horizon Store takes TWA-based APKs built with Meta's Bubblewrap fork; the Galaxy Store accepts ordinary Android packages; Amazon retired its Appstore on Android phones in August 2025 and keeps it only on Fire devices.
  • Web deploys still update the app. Your pages, scripts and service worker update from your server for every store version. Only changes baked into the package, such as the name, icons, handlers or target SDK, need a new build and review.
  • Store rules follow your code into the web view. Play's payments policy, Apple's in-app purchase rules and the Microsoft Store's commerce policy apply to the web content, so detect the distribution channel at runtime and gate purchase flows accordingly.

Store distribution options at a glance

Store What you upload Tool What runs your code Developer account (September 2026) Web updates without resubmission
Google Play Android App Bundle (.aab) containing a TWA launcher Bubblewrap, PWABuilder, Android Studio The user's TWA-capable browser, usually Chrome US$25 one-time; personal accounts created after November 13, 2023 must run a 12-tester, 14-day closed test ✅
Microsoft Store .msixbundle plus .classic.appxbundle PWABuilder Microsoft Edge's web app runtime Free for individuals and companies; ID or business verification ✅, but manifest changes need a new package
Apple App Store Native iOS app (.ipa via Xcode) PWABuilder iOS template, Capacitor, custom Swift WKWebView inside your app US$99 per membership year ✅ for hosted content, subject to guideline 2.5.2 and 4.2
Meta Horizon Store Android APK with a TWA launcher @meta-quest/bubblewrap-cli Meta Quest Browser Meta developer account ✅
Samsung Galaxy Store APK or AAB (Samsung generates a universal APK) Same as Google Play TWA-capable browser No sign-up or annual fee ✅
Amazon Appstore (Fire tablets) Hosted web app URL or packaged web app Amazon developer console Amazon's Chromium-based web app runtime Amazon developer account ✅ for hosted apps

The costs and requirements in this table change often. Confirm them in each store's current documentation, linked in Further reading, before you plan a launch.

Should you publish to a store at all?

A PWA is already installable from the browser on every major platform (see Installation by Platform). Store distribution is an addition, and it has costs.

Benefits Costs and risks
Discovery Store search, category browsing, editorial features, and users who only look for apps in stores Store listing work: screenshots, descriptions, age ratings, privacy and data-safety declarations
Trust Users recognize the store badge; enterprise device management can deploy store apps A review process that can reject or delay releases, and an account that can be suspended
Integration Android App Links, notification delegation without a browser prompt, Play Billing, store ratings, Windows Store deep links A second identity to keep in sync: package names, signing keys, fingerprints, target SDKs
Billing Access to store payment systems and their user trust Store commissions and payment rules apply to purchases inside the web content
Updates Web content updates instantly, with no store review Package-level changes (icons, name, handlers, target API level) require rebuilds; Play's annual target-SDK deadline forces periodic updates
Platform reach The only way to be in Apple's App Store at all, and a native launcher entry for Android users of browsers that can't mint WebAPKs On iOS, a wrapper loses Web Push and Home Screen web app behavior and must add native value to pass review

Two practical guidelines. First, publish to Google Play and the Microsoft Store when discovery matters; both accept PWAs as first-class submissions and the web code needs no changes. Second, treat the Apple App Store as a separate native project with a web core, and budget for native features, not just packaging.

Google Play with a Trusted Web Activity

A Trusted Web Activity (TWA) is an Android activity that opens your origin full screen in the user's browser, without browser UI, after verifying that the app and the site belong to the same owner. The Android app you upload is a small launcher; your PWA runs in Chrome (or another TWA-capable browser) with its full feature set and its storage. Trusted Web Activity covers the launch flow, fallbacks, notification and location delegation, and multi-origin configuration in depth. This section focuses on getting the app into Play.

flowchart LR
    A["Your PWA<br/>manifest + service worker"] --> B["Bubblewrap or PWABuilder<br/>generates Android project"]
    B --> C["Signed .aab<br/>upload key"]
    C --> D["Play Console<br/>Play App Signing re-signs"]
    D --> E["User installs from Play"]
    E --> F{"assetlinks.json lists<br/>the Play signing key?"}
    F -- "yes" --> G["Full-screen TWA in Chrome"]
    F -- "no" --> H["Browser UI visible<br/>(Custom Tab fallback)"]

Requirements before you start

  • A PWA that passes the install criteria, served over HTTPS, with a manifest that has a 512 px icon (Bubblewrap requires an icon of at least 512×512), and a service worker that handles offline and error cases. Google's 2020 quality criteria for TWAs are no longer enforced, but they remain useful guidance; see below.
  • A Google Play Console account. Registration is a US$25 one-time fee. Google may ask for a government ID and a card in your legal name. Personal accounts created after November 13, 2023 must run a closed test with "a minimum of 12 testers who have been opted in continuously for at least 14 days" before they can apply for production access; organization accounts are exempt.
  • A target API level that meets Play's current rule. From August 31, 2026, "New apps and app updates must target Android 16 (API level 36) or higher to be submitted to Google Play", with exceptions for Wear OS, Automotive, TV and XR. Existing apps must target API level 35 to remain available to new users on newer devices, and extensions to November 1, 2026 can be requested. Bubblewrap 1.25.0 (July 31, 2026) bumped its generated targetSdkVersion to 36; older generated projects need bubblewrap update and a rebuild.
  • Tooling. Bubblewrap needs Node.js and downloads a JDK and the Android command-line tools on first run. Its README requires JDK 17 if you set up the environment manually: lower versions can't compile the project and newer ones are incompatible with the Android command-line tools.

Android developer verification

Separate from Play publishing, Google's Android developer verification program requires apps on certified Android devices (Android 7 and later) to be registered by a verified developer. It becomes required on September 30, 2026 in Brazil, Indonesia, Singapore and Thailand, for apps from participating stores (Google lists Google Play, Galaxy Store, HONOR App Market, OPPO App Market, Palm Store, V-Appstore and GetApps), with global expansion from 2027. Google says Play registers the vast majority of Play apps automatically; the Android Developer Console is for apps distributed only outside Play. It matters most if you also ship your TWA APK through another store or as a direct download. Limited distribution accounts (up to 20 devices) and an "advanced flow" for power users who sideload unverified apps also exist. See Android developer verification.

Step by step with Bubblewrap

Bubblewrap is Google Chrome Labs' CLI for generating and building TWA projects. PWABuilder's Android packager uses it internally.

Terminal
# 1. Install the CLI (don't use sudo with npm).
npm i -g @bubblewrap/cli

# 2. Generate an Android project from your live manifest. Bubblewrap asks you to
#    confirm the package ID, names, colors, icons and signing key details.
mkdir my-twa && cd my-twa
bubblewrap init --manifest=https://app.example.com/manifest.webmanifest

# 3. Check the JDK and Android SDK it will use.
bubblewrap doctor

# 4. Build. Produces app-release-signed.apk (for testing) and
#    app-release-bundle.aab (for Play). Passwords can come from the environment
#    for CI: BUBBLEWRAP_KEYSTORE_PASSWORD and BUBBLEWRAP_KEY_PASSWORD.
bubblewrap build

# 5. Install the signed APK on a connected device or emulator.
bubblewrap install

# 6. Print a Digital Asset Links file for the fingerprints in twa-manifest.json.
bubblewrap fingerprint generateAssetLinks

init writes a twa-manifest.json, which is the source of truth for the Android project. Edit it and run bubblewrap update to regenerate the project; the README warns that files you add to the generated Android project by hand "will be deleted or overwritten when update is executed". bubblewrap merge pulls changes from your web manifest into twa-manifest.json, and bubblewrap validate --url=<pwa-url> was designed to check the PWA against the 2020 TWA quality criteria. It depends on the Lighthouse PWA category, which Lighthouse 12 removed, so it can't pass as designed; use Lighthouse & Auditing and Core Web Vitals instead.

A representative configuration, using fields documented in Bubblewrap's README:

twa-manifest.json
{
  "packageId": "com.example.app.twa",
  "host": "app.example.com",
  "name": "Example Orders",
  "launcherName": "Orders",
  "startUrl": "/?source=twa",
  "webManifestUrl": "https://app.example.com/manifest.webmanifest",
  "iconUrl": "https://app.example.com/icons/icon-512.png",
  "maskableIconUrl": "https://app.example.com/icons/maskable-512.png",
  "monochromeIconUrl": "https://app.example.com/icons/monochrome-512.png",
  "themeColor": "#0B57D0",
  "themeColorDark": "#0B1D3A",
  "navigationColor": "#0B57D0",
  "navigationColorDark": "#000000",
  "navigationDividerColor": "#000000",
  "navigationDividerColorDark": "#000000",
  "backgroundColor": "#FFFFFF",
  "display": "standalone",
  "orientation": "default",
  "enableNotifications": true,
  "enableSiteSettingsShortcut": true,
  "fallbackType": "customtabs",
  "splashScreenFadeOutDuration": 300,
  "appVersionCode": 12,
  "appVersion": "1.4.0",
  "signingKey": {
    "path": "./android.keystore",
    "alias": "android"
  },
  "fingerprints": [
    { "name": "upload key", "value": "AA:BB:...:FF" },
    { "name": "Play App Signing", "value": "11:22:...:99" }
  ],
  "features": {
    "playBilling": { "enabled": true },
    "locationDelegation": { "enabled": false }
  },
  "additionalTrustedOrigins": ["checkout.example.com"],
  "shortcuts": []
}

Notes on the fields that cause the most trouble:

  • packageId is permanent. Every update on Play must use the same application ID and the same signing lineage.
  • startUrl is relative to host. Adding ?source=twa gives you launch attribution without relying on the android-app:// referrer, which only the first navigation carries; see Detecting Installed Apps.
  • fallbackType decides what happens when the device has no TWA-capable browser: customtabs (the default) shows a Custom Tab with browser UI; webview loads your site in a WebView, which has a different storage jar and a reduced feature set.
  • additionalTrustedOrigins lists other origins you own; each must serve its own assetlinks.json naming this package.
  • enableNotifications turns on notification delegation, so notifications from your origin are shown as the Android app's notifications. On Android 13 and later, the app still needs the user's runtime notification permission.

Step by step with PWABuilder

PWABuilder wraps the same Bubblewrap pipeline in a web UI:

  1. Enter your PWA's URL at pwabuilder.com and review the report card.
  2. Choose Package for stores, then Generate Package under Android, on the Google Play tab.
  3. Confirm the package ID, names, colors, icons, display mode, notification and location delegation, and Play Billing.
  4. For a new app, let PWABuilder create a signing key. For an update, choose Use mine and provide your existing keystore, alias and passwords.
  5. Download the ZIP. It contains the .aab, a signed test APK, an assetlinks.json, and signing.keystore with signing-key-info.txt. Store the key files safely; PWABuilder's documentation notes you need them "to deploy future versions of your app".

PWABuilder's packages require Android 7.0 (API level 24) or newer, per its documentation. The PWABuilder page covers the tool itself.

A TWA shows your site without browser UI only if the browser can verify a two-way association:

  1. The app points at the site. Bubblewrap generates an asset_statements resource in the Android project that declares the delegate_permission/common.handle_all_urls relation for https://app.example.com.
  2. The site points at the app. Your origin serves a statement list at exactly https://app.example.com/.well-known/assetlinks.json that names the package and the SHA-256 fingerprint of the certificate that signed the installed APK.
.well-known/assetlinks.json
[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app.twa",
      "sha256_cert_fingerprints": [
        "11:22:33:44:55:66:77:88:99:00:AA:BB:CC:DD:EE:FF:11:22:33:44:55:66:77:88:99:00:AA:BB:CC:DD:EE:FF",
        "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
      ]
    }
  }
]

The critical detail is which fingerprint. When you upload an App Bundle, Play App Signing re-signs the app with Google's app signing key, so the APK users install is not signed by your upload key. Copy the app signing key's SHA-256 from the App integrity page of your app in Play Console (its App signing tab also offers a ready-made Digital Asset Links JSON snippet; the page's place in the navigation has moved between console redesigns) and add it to the list. Keep your upload key's fingerprint as well, so APKs you sideload for testing verify too. If you also publish the same package elsewhere, such as the Galaxy Store with its own signing, add that store's fingerprint, or use a different package name per store, as Samsung recommends for apps that use its in-app purchase.

Rules that are easy to break:

  • The file must be at the root of the exact origin in host, under /.well-known/, not under your app's path. https://example.com/app/.well-known/assetlinks.json is ignored.
  • Serve it over HTTPS as application/json, without redirects. A redirect from example.com to www.example.com means verification looks at the wrong host; generate the package for the canonical host your site ends up on.
  • Every origin in additionalTrustedOrigins needs its own file.
  • Chrome caches verification results. After fixing the file, clear Chrome's data for the site (or reinstall the browser) on test devices before concluding it still fails.

Check the file the way Google does, from the command line:

check-asset-links.sh
#!/usr/bin/env bash
# Verifies that an origin's Digital Asset Links statement authorizes an Android package
# signed with a given certificate. Usage:
#   ./check-asset-links.sh https://app.example.com com.example.app.twa AA:BB:...:FF
set -euo pipefail

origin="${1:?origin}"
package="${2:?package name}"
fingerprint="${3:?SHA-256 fingerprint}"

# 1. The raw file: status, content type and no redirects.
curl --silent --show-error --output /dev/null \
  --write-out 'HTTP %{http_code}  %{content_type}  redirects=%{num_redirects}\n' \
  "$origin/.well-known/assetlinks.json"

# 2. Google's Digital Asset Links API, which is what verification relies on.
curl --silent --get "https://digitalassetlinks.googleapis.com/v1/statements:list" \
  --data-urlencode "source.web.site=$origin" \
  --data-urlencode "relation=delegate_permission/common.handle_all_urls" \
  | node -e '
      const [pkg, fp] = process.argv.slice(1);
      let raw = "";
      process.stdin.on("data", (c) => (raw += c)).on("end", () => {
        const { statements = [] } = JSON.parse(raw);
        const ok = statements.some((s) =>
          s.target?.androidApp?.packageName === pkg &&
          s.target.androidApp.certificate?.sha256Fingerprint?.toUpperCase() === fp.toUpperCase());
        console.log(ok ? "OK: package and fingerprint are authorized"
                       : "MISSING: no statement for this package and fingerprint");
        process.exit(ok ? 0 : 1);
      });
    ' "$package" "$fingerprint"

To read a certificate fingerprint locally, run keytool -list -v -keystore android.keystore -alias android; Bubblewrap's fingerprint add and fingerprint generateAssetLinks commands keep twa-manifest.json and the generated file in sync.

Uploading and configuring the Play listing

  1. In Play Console, create the app, choose app or game, free or paid, and accept the declarations.
  2. Upload the .aab to the internal testing track first. From CI, bubblewrap play publish --serviceAccountFile=service-account.json --track=internal --appBundleLocation=app-release-bundle.aab does this with a Google Cloud service account that has access to your Play Console app (the internal track is the default).
  3. Enroll in Play App Signing (the default for new apps), copy the app signing fingerprint into assetlinks.json, deploy it, and install from the internal track to confirm the address bar is gone.
  4. Complete the store listing, content rating questionnaire, target audience, and Data safety form. The data safety answers must cover what your web content collects, not only the thin Android shell.
  5. For new personal accounts, promote to a closed track with at least 12 testers for 14 continuous days, then apply for production access from the dashboard.
  6. Release to production.

The first launch of a TWA shows a small disclosure that the app runs in the browser; PWABuilder's asset links FAQ says a "Chrome is in use" banner on first run is expected and "not evidence of a broken asset links". Browser UI at the top of the screen is the real failure signal.

PWABuilder's documentation also warns that "PWAs on Android cannot currently target children as their audience" and recommends setting the target audience to older users. If your app is designed for children, check Play's Families policies before choosing a TWA.

TWA quality criteria: historical, but still worth meeting

In June 2020, Google announced that from Chrome 86, three web failures inside a TWA would be treated like native crashes and reported to Android vitals: "an HTTP 404 or 5xx error in the application", "failure to return HTTP 200 for an offline network resource request", and "failure to verify digital asset links at application launch". The same announcement restated that TWA content "must meet PWA installability criteria and load fast at the start URL", measured with Lighthouse at the start URL, where it "must achieve a performance score of 80". None of this is enforced any more: Chromium removed the crash-based enforcement in 2023 (Chromium 115), and the Lighthouse PWA category it relied on no longer exists. Treat the criteria as historical guidance that still describes a good TWA: a browser error page inside a store-installed app is a bad review whether or not anything reports it. The full history is on Trusted Web Activity.

In practice this means your service worker should answer every in-scope navigation, online or offline, with a real page:

sw.js
// Navigation handler that keeps a TWA from showing browser error pages.
// Precache /offline.html and /not-found.html during install.
const OFFLINE_URL = "/offline.html";
const NOT_FOUND_URL = "/not-found.html";

self.addEventListener("fetch", (event) => {
  if (event.request.mode !== "navigate") return;

  event.respondWith(
    (async () => {
      try {
        const preload = await event.preloadResponse; // navigation preload, if enabled
        const response = preload || (await fetch(event.request));
        if (response.status === 404) {
          const page = await caches.match(NOT_FOUND_URL);
          // Keep the 404 status for crawlers and analytics but render a branded page.
          if (page) return new Response(page.body, { status: 404, headers: page.headers });
        }
        if (response.status >= 500) {
          return (await caches.match(OFFLINE_URL)) || response;
        }
        return response;
      } catch {
        // Network failure: never let the browser's offline page appear.
        return (
          (await caches.match(event.request, { ignoreSearch: true })) ||
          (await caches.match(OFFLINE_URL)) ||
          new Response("Offline", { status: 503, headers: { "Content-Type": "text/plain" } })
        );
      }
    })()
  );
});

Offline UX & Fallbacks and Navigation Preload cover the production versions of this handler.

Play policies that apply to PWAs

  • Web wrappers must be yours. Play's spam policy: "We don't allow apps whose primary purpose is to drive affiliate traffic to a website or provide a webview of a website without permission from the website owner or administrator." A TWA is fine for your own origin; Digital Asset Links is how Play and Chrome know it's yours.
  • All Play policies apply to the web content. Google's TWA announcement is explicit that apps using a TWA "must comply with all Play store policies, including for web content in the Trusted Web Activity, including policies for payments in-app purchases and other digital goods." A checkout that is fine on the web can violate Play's payments policy inside the app.
  • Payments policy. Outside the exceptions below, digital goods and subscriptions sold inside a Play-distributed app must use Google Play Billing. Physical goods and services use your normal web checkout.
  • United States. To comply with the injunction in the Epic Games case, Google changed its rules for apps on phones and tablets serving users in the United States from October 29, 2025: it doesn't require Play Billing, doesn't prohibit other in-app payment methods, and doesn't prohibit linking to transactions or downloads outside Play. Using those freedoms means following the Payments policy and enrolling in the alternative billing or external content links programs launched on December 9, 2025. Google announced that enrolled developers must report transactions and pay the programs' service fees from October 1, 2026 (for external content links, the deadline was later moved to December 1, 2026), and that Google and Epic reached a new settlement on March 4, 2026 that asks the court for a revised injunction. The Play Console Help article is the live source; check it before you design a US checkout. Other regions have their own user-choice or alternative billing programs.

Selling digital goods with Play Billing from web code

Inside a TWA with Play Billing enabled (Bubblewrap's playBilling feature, or PWABuilder's Google Play billing option), your web code can use two APIs: the Digital Goods API to query products and purchases, and the Payment Request API with the https://play.google.com/billing payment method to buy them. Chrome's documentation lists Chrome 101 or later on Android and ChromeOS.

play-billing.js
// Google Play Billing from a PWA running in a Trusted Web Activity.
// Requires the TWA to be built with Play Billing enabled; in a normal browser tab,
// getDigitalGoodsService() is missing or rejects, and callers fall back to web checkout.

const PLAY_BILLING = "https://play.google.com/billing";

let servicePromise;

/** Resolves to the Digital Goods service, or null when Play Billing isn't available. */
export function getPlayBilling() {
  servicePromise ??= (async () => {
    if (!("getDigitalGoodsService" in window)) return null;
    try {
      return await window.getDigitalGoodsService(PLAY_BILLING);
    } catch {
      return null; // not in a Play-installed TWA, or billing unavailable
    }
  })();
  return servicePromise;
}

/** Localized product details for your UI (price strings come from Play). */
export async function getProducts(itemIds) {
  const service = await getPlayBilling();
  if (!service) return [];
  const details = await service.getDetails(itemIds);
  return details.map((d) => ({
    id: d.itemId,
    title: d.title,
    description: d.description,
    price: new Intl.NumberFormat(navigator.language, {
      style: "currency",
      currency: d.price.currency,
    }).format(Number(d.price.value)),
  }));
}

/**
 * Starts a purchase. Must be called from a user gesture (click handler).
 * Returns the purchase token after the server has verified and acknowledged it.
 */
export async function buy(itemId, { consumable = false } = {}) {
  // Call getPlayBilling() once at startup so this await resolves immediately and
  // show() still runs within the click's transient user activation.
  const service = await getPlayBilling();
  if (!service) throw new Error("Play Billing is not available in this context");

  const request = new PaymentRequest(
    [{ supportedMethods: PLAY_BILLING, data: { sku: itemId } }],
    // Play shows its own price; the total is required by the API but not displayed.
    { total: { label: "Total", amount: { currency: "USD", value: "0" } } }
  );

  const response = await request.show(); // rejects with AbortError if the user cancels
  const { purchaseToken } = response.details;

  try {
    // Verify and acknowledge on your server. Unacknowledged purchases are refunded.
    const result = await fetch("/api/play/purchases", {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ itemId, purchaseToken }),
    });
    if (!result.ok) throw new Error(`Verification failed: ${result.status}`);
    await response.complete("success");
  } catch (error) {
    await response.complete("fail");
    throw error;
  }

  if (consumable) {
    // Consuming lets the user buy the same item again (credits, tokens).
    await service.consume(purchaseToken);
  }
  return purchaseToken;
}

/** Restores entitlements on startup, for example after a reinstall. */
export async function restorePurchases() {
  const service = await getPlayBilling();
  if (!service) return [];
  const purchases = await service.listPurchases(); // [{ itemId, purchaseToken }]
  await fetch("/api/play/purchases/restore", {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(purchases),
  });
  return purchases;
}

Server-side, verify each token with the Google Play Developer API and acknowledge one-time products with POST https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/products/{productId}/tokens/{token}:acknowledge, authorized with the https://www.googleapis.com/auth/androidpublisher scope. Chrome's billing guide warns: "If you do not acknowledge the purchase, after three days, the user will receive a refund and Google Play will revoke the purchase." Grant entitlements from your server's verified record, never from the client's claim. Payments covers the Payment Request API in general.

Updating a Play-distributed PWA

Change What you do
HTML, CSS, JavaScript, service worker, content Deploy to your server. Every installed TWA gets it on the next load, as a browser tab would. See Updating Service Workers.
App name, launcher icon, colors, splash, shortcuts, share target, protocol or file handlers Edit twa-manifest.json (or run bubblewrap merge), run bubblewrap update, rebuild, increment appVersionCode, and upload a new release.
Target API level (annual Play deadline) Update Bubblewrap, run bubblewrap update, rebuild and release.
Domain move Serve assetlinks.json on the new origin before releasing a build whose host points at it; keep the old origin redirecting for old builds.

Keep the keystore and passwords in a secret store. Losing the upload key is recoverable through Play's upload key reset; losing it for a store that doesn't manage signing, or for sideloaded builds, isn't.

Microsoft Store

The Microsoft Store accepts PWAs as first-class products. A Store PWA is an MSIX package that carries your manifest data; installing it registers the app with Windows, and Microsoft Edge runs it. Microsoft's documentation stresses that "no code changes are required".

Account requirements in 2026

  • Individual developers register for free since September 2025, through the flow at storedeveloper.microsoft.com, with identity verification by "a government-issued ID and selfie". Microsoft says the waived fee was $19. Microsoft's documentation notes that other entry points "will show the legacy flow".
  • Company accounts no longer pay the former $99 fee: Microsoft's Windows Developer Blog announced on May 7, 2026 that "The $99 onboarding fee for company developer accounts has been removed." Business verification uses a D-U-N-S number or company documents, and Microsoft Entra ID work accounts can sign up.
  • Microsoft's PWA publishing guide still states that app reservation requires a personal Microsoft account enrolled in the developer program. Follow whichever flow the current onboarding site gives you for your account type.

Step by step

  1. Reserve the name. In Partner Center, open Apps and games, choose New product > MSIX or PWA app, and reserve the product name.
  2. Collect the identity values. Under Product management > Product Identity, copy the Package ID, Publisher ID and Publisher display name.
  3. Generate the packages. At pwabuilder.com, enter your URL, choose Package for stores, then Generate Package under Windows, and paste the three values. The download is a ZIP containing an .msixbundle and a .classic.appxbundle; Microsoft explains that "The two app packages allow your PWA to run on a wide variety of Windows versions."
  4. Test locally. Install the package from the ZIP on a test machine (PWABuilder includes a test package option) and check the name, icons, Start menu entry and file or protocol handlers.
  5. Submit. In Partner Center, Start your submission, complete pricing, properties, age rating and the Store listing, and upload both package files in Packages. PWABuilder notes that warnings about restricted capabilities such as runFullTrust "can be safely ignored".
  6. Wait for review. Microsoft says review typically takes "24 to 48 hours".

The most common rejection before review even starts is "This package's manifest uses a display name that you have not reserved": the app name in PWABuilder must match the reserved name exactly.

Microsoft Store policies that matter for PWAs

  • Ownership. Policy 10.1 (document version 7.20, published September 15, 2026 and effective October 22, 2026): "Products submitted as web apps must be published by the domain or website owner."
  • Value and functionality. Policy 10.1.2 requires that your product "must be fully functional", and 10.1.4 that it provide "a valuable and quality user experience". A PWA that works offline and integrates with Windows features has an easier review than a bare website.
  • Payments. Under policy 10.8.1, games and products on Xbox consoles must use the Microsoft Store in-product purchase APIs for digital goods, but "Non-game products made available on PC devices may either use a secure third-party purchase API or the Microsoft Store in-product purchase API". For most non-game PWAs, your existing web checkout is allowed.

What updates need a new package

Microsoft's guide: "Generally, when you update your PWA code, you don't need to create a new app package and submit it to the Microsoft Store again." But "if you make changes to the web app manifest file, you must create a new app package and submit it", because "the information in the web app manifest file is copied to the Windows app package". Examples it gives: the icon, the name, file_handlers, protocol_handlers, share_target. Keep a changelog of manifest edits so the Store package doesn't silently drift from the web manifest.

Windows-specific integration

  • Launch attribution. On the first navigation of a Store-installed PWA, Edge sends Referer: app-info://platform/microsoft-store, and document.referrer exposes the same value. Record it on the first load; PWABuilder notes it's empty after navigation or reload.
  • Detecting the Store app from the web. Add a related_applications entry with "platform": "windows" and the package family name plus app ID, then call navigator.getInstalledRelatedApps(); see Detecting Installed Apps.
  • Web-to-app link association. PWABuilder documents a windows.appUriHandler extension in the package manifest, backed by a windows-web-app-link file on your server listing the package family name.
  • Rating prompts. Navigating to ms-windows-store://review/?ProductId=<id> opens the Store's rating dialog for your app.
  • Locale redirects. A Store PWA's start_url is fixed to the domain in the package. If your site redirects to a locale-specific domain, Microsoft notes that the redirected page is out of scope and shows the URL and title bar, and that it's "currently impossible" to suppress for Store-installed apps.

Apple App Store

Apple doesn't accept PWAs as App Store submissions. The platform-supported way to install a PWA on iPhone, iPad and Mac is the browser: Add to Home Screen and Add to Dock, covered in Installation by Platform and iOS & iPadOS. To be in the App Store, you ship a native app whose UI is, wholly or partly, your web app in a WKWebView.

The review guidelines that decide the outcome

The App Review Guidelines (last updated June 8, 2026) contain the rules that wrapper apps run into:

  • 4.2 Minimum Functionality: "Your app should include features, content, and UI that elevate it beyond a repackaged website. If your app is not particularly useful, unique, or 'app-like,' it doesn't belong on the App Store."
  • 4.2.2: "Other than catalogs, apps shouldn't primarily be marketing materials, advertisements, web clippings, content aggregators, or a collection of links."
  • 4.2.6: "Apps created from a commercialized template or app generation service will be rejected unless they are submitted directly by the provider of the app's content." Submit under your own developer account.
  • 2.5.2: apps may not "download, install, or execute code which introduces or changes features or functionality of the app". A hosted web app whose features change with every deploy sits in tension with this rule; reviewers judge the app they see.
  • 2.5.6: "Apps that browse the web must use the appropriate WebKit framework and WebKit JavaScript", with entitlements for alternative engines only in the EU and Japan.
  • 3.1.1 In-App Purchase: "If you want to unlock features or functionality within your app […] you must use in-app purchase." Guideline 3.1.1(a) and 3.1.3 add that buttons, external links and calls to action to other purchase methods are allowed on the United States storefront, and elsewhere only through Apple's entitlement programs.

Apple doesn't publish rejection statistics for wrappers. PWABuilder labels its iOS packaging experimental and says acceptance "depends only on UI/UX of your PWA and usage of native capabilities such as Push Notification, In-app purchase, etc."

Account and build requirements

  • The Apple Developer Program costs "99 USD per membership year"; PWABuilder's guide notes that nonprofits can have the fee waived.
  • Since April 28, 2026, apps uploaded to App Store Connect "must be built with Xcode 26 or later using an SDK for iOS 26", and since September 9, 2026 iOS apps "must target iOS 13 or later". Building requires a Mac with Xcode, or a hosted macOS build service.

Wrapper options

Option What you get Maintenance
PWABuilder iOS package An Xcode project with a Swift shell hosting your URL in WKWebView, splash screen, status bar colors, permitted URLs, and commented-out Firebase Cloud Messaging code for push Community-driven: the project's README says there "will not be devs from the PWABuilder team maintaining and building out iOS functionality"
Capacitor (Ionic) A native project that bundles your built web assets into the app, a JavaScript bridge to native plugins (push, in-app purchase through community plugins, haptics, filesystem), and the same codebase for Android Actively maintained; the current major line is Capacitor 8 (8.5.2, September 2026)
Custom Swift app Full control: native tabs or navigation around web content, native screens for the features that justify the app Your team owns everything

Capacitor's server.url option, which loads a remote URL instead of bundled assets, is documented as "intended for use with live-reload servers" and "not intended for use in production". For an App Store build, bundle your web build into the app; it's also what makes the app work offline on first launch and reduces the 2.5.2 risk.

Service workers in WKWebView: App-Bound Domains

WKWebView doesn't give every app a service worker. WebKit introduced App-Bound Domains in iOS 14: an app lists up to 10 domains under the WKAppBoundDomains key in Info.plist, and a web view configured with limitsNavigationsToAppBoundDomains "will only navigate to app-bound domains". PWABuilder's iOS template relies on App-Bound Domains to enable service workers for your PWA on iOS 14 and later. Capacitor exposes the same switch as ios.limitsNavigationsToAppBoundDomains and notes that, when WKAppBoundDomains is present, "some features won't work" without it; its local hostname (localhost by default) must then be in the list too.

Info.plist (excerpt)
<key>WKAppBoundDomains</key>
<array>
    <string>app.example.com</string>
    <string>accounts.example.com</string>
</array>
WebViewController.swift
import UIKit
import WebKit

/// Hosts the PWA in a WKWebView restricted to App-Bound Domains, which enables
/// service workers and keeps navigation inside domains you control.
final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
    private let startURL = URL(string: "https://app.example.com/?source=ios-app")!
    private var webView: WKWebView!

    /// Read the list from Info.plist so the code and WKAppBoundDomains can't drift apart.
    private lazy var appBoundHosts: Set<String> = Set(
        (Bundle.main.object(forInfoDictionaryKey: "WKAppBoundDomains") as? [String]) ?? []
    )

    override func loadView() {
        let config = WKWebViewConfiguration()
        config.limitsNavigationsToAppBoundDomains = true
        // Persistent store: cookies, IndexedDB and Cache Storage survive relaunches.
        config.websiteDataStore = .default()
        config.allowsInlineMediaPlayback = true

        webView = WKWebView(frame: .zero, configuration: config)
        webView.navigationDelegate = self
        webView.uiDelegate = self
        webView.allowsBackForwardNavigationGestures = true
        view = webView
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        webView.load(URLRequest(url: startURL))
    }

    // Keep app-bound navigations in the web view; hand everything else to the system.
    func webView(_ webView: WKWebView,
                 decidePolicyFor navigationAction: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard let url = navigationAction.request.url else {
            decisionHandler(.cancel)
            return
        }
        // Local schemes (about:blank, blob:, data:) never leave the web view.
        if ["about", "blob", "data"].contains(url.scheme ?? "") {
            decisionHandler(.allow)
            return
        }
        // Subframes (embedded players, payment iframes) must not bounce the user to Safari.
        // With limitsNavigationsToAppBoundDomains, WebKit still blocks non-app-bound loads.
        let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true
        if let host = url.host, appBoundHosts.contains(host) || !isMainFrame {
            decisionHandler(.allow)
        } else {
            // Other web origins, tel:, mailto: and universal links go to the system.
            UIApplication.shared.open(url)
            decisionHandler(.cancel)
        }
    }

    // target="_blank" and window.open(): load app-bound URLs here, send the rest out.
    func webView(_ webView: WKWebView,
                 createWebViewWith configuration: WKWebViewConfiguration,
                 for navigationAction: WKNavigationAction,
                 windowFeatures: WKWindowFeatures) -> WKWebView? {
        if let url = navigationAction.request.url {
            if let host = url.host, appBoundHosts.contains(host) {
                webView.load(navigationAction.request)
            } else {
                UIApplication.shared.open(url)
            }
        }
        return nil // no pop-up web views
    }

    // Reload instead of showing a blank screen when iOS kills the web content process.
    func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
        webView.reload()
    }
}

What your PWA loses and gains inside a wrapper

  • Web Push doesn't apply. Web Push on iOS is for Home Screen web apps and Safari. A wrapper sends notifications through APNs from native code (directly, or through a service such as Firebase Cloud Messaging), and your web code talks to it through a bridge. See Web Push on iOS & Safari for the browser path.
  • Storage belongs to the app. The wrapper's WKWebsiteDataStore is separate from Safari and from any Home Screen copy of your site. Users sign in again.
  • Payments follow App Store rules. Digital goods need StoreKit in-app purchase outside the exceptions above, so your web checkout must be hidden or replaced inside the wrapper.
  • You can add what justifies the app. Native push with rich actions, widgets, Siri and Shortcuts intents, share extensions, background tasks and in-app purchase are the kind of "features, content, and UI" 4.2 asks for.

Step by step with PWABuilder's iOS package

  1. Generate the iOS package at pwabuilder.com and note the Bundle ID.
  2. Unzip, run pod install in src, and open the .xcworkspace (not the .xcodeproj).
  3. In Signing & Capabilities, remove capabilities the app doesn't use.
  4. In your Apple Developer account, create the App ID with that Bundle ID, a distribution certificate and an App Store provisioning profile.
  5. Create the app record in App Store Connect, fill in metadata, privacy details and screenshots.
  6. Archive with Product > Archive for Any iOS Device (arm64) and upload with Distribute App > App Store Connect.
  7. Submit for review. Explain in the review notes which native features the app provides.

PWABuilder's wrapper sets a cookie named app-platform with the value iOS App Store, which your web code and server can read to detect the wrapper.

Samsung Galaxy Store

Samsung announced in October 2019 that Progressive Web Apps would be listed in the Galaxy Store, compiled into WebAPKs, initially in the US store only, with onboarding handled by email ([email protected]). Samsung hasn't published updated documentation for that program since, so treat it as something to confirm with Samsung before relying on it.

The dependable path in 2026 is the one any Android app uses: submit your TWA package through the Galaxy Store Seller Portal. Samsung's FAQ states there's "no sign-up nor annual fee to publish in Galaxy Store"; that Seller Portal accepts AAB files and "Galaxy Store generates a universal APK from the AAB file"; and that the store "requires a target API level >=33 and at least one 64-bit binary". Publishing is free, but Samsung takes a revenue share on transactions that use Samsung Checkout. For apps that use Samsung In-App Purchase and are also on Google Play, Samsung recommends a different package name per store (for example com.myapp.samsung and com.myapp.google) to prevent cross-store updates; that's good practice for any TWA whose Galaxy Store build is signed with a different key. With a separate package name, add a second statement to assetlinks.json for it, with the fingerprint of the key that signs the Galaxy Store build. Galaxy Store is also one of the stores Google lists as participating in Android developer verification, described above.

Which browsers run a TWA

A store-installed TWA runs in the user's default browser if that browser supports TWAs, otherwise in another installed browser that does, otherwise in the fallbackType. The android-browser-helper project maintains the compatibility table:

Browser Trusted Web Activity Splash screen Notification delegation
Chrome ✅ since 72 ✅ ✅
Edge ✅ since 45.05 ✅ ❌
Samsung Internet ✅ since 13.0.2.9 ❌ ❌
Brave ✅ ✅ ✅
Vivaldi ✅ ✅ ✅
Firefox ⚠️ Nightly preview only ❌ ❌
Opera, DuckDuckGo, UC Browser, Yandex, Naver Whale, Silk ❌ ❌ ❌

Support data as of September 2026, from the android-browser-helper browser support table, which notes it's maintained on a best-effort basis. Two consequences matter for Galaxy Store users, many of whom default to Samsung Internet: the TWA runs full screen there, but without the native splash screen and without notification delegation, so notifications arrive as Samsung Internet's web notifications (subject to its permission prompt) rather than as your app's. Test notification flows with Samsung Internet as the default browser before you launch in the Galaxy Store.

Meta Horizon Store (Meta Quest)

Meta Quest headsets run Meta Horizon OS, which is Android-based, and the Horizon Store accepts PWAs packaged as Android apps with a Trusted Web Activity running in Meta Quest Browser. Meta's packaging guide describes two app modes: 2D, "for a windowed site, including screen-based 3D", and immersive, "for an app that launches directly into WebXR".

Meta maintains its own Bubblewrap fork:

Terminal
# Meta's fork of Bubblewrap (Node.js 18 or later).
npm install --global @meta-quest/bubblewrap-cli

# Choose 2D or immersive, the package ID, orientation, and Horizon Billing if you
# sell items in an immersive WebXR app (needs your Meta Horizon Application ID).
bubblewrap init --manifest=https://example.com/manifest.webmanifest --metaquest

# Add the signing certificate's fingerprint and publish the generated assetlinks.json.
keytool -list -v -keystore ./android.keystore -alias android
bubblewrap fingerprint add "$SHA256_FINGERPRINT"   # the value keytool printed

bubblewrap build                       # outputs app-release-signed.apk
adb install app-release-signed.apk     # sideload to a Quest in developer mode to test

Meta's guide stresses the same identity rules as Play: "Every update to an existing Store app must use the same package identifier", updates must be signed with the same key, and Digital Asset Links failures are fatal in one mode: "An immersive PWA does not launch if this verification fails. A 2D PWA displays custom tab UI when its web origin is not verified." If you sell items with Horizon Billing, configure the in-app purchases in the Developer Dashboard before packaging. The fork stores the chosen mode as horizonOSAppMode in twa-manifest.json. Google's Bubblewrap also has a --metaquest flag, which sets isMetaQuest, a minimum SDK of 23, and requires fullScopeUrl; Meta's guide uses its own package. Upload and store submission happen in Meta's Developer Dashboard. For WebXR design and testing, see Hardware & Device APIs.

Other stores in 2026

  • Amazon Appstore. Amazon discontinued its Appstore for Android devices on August 20, 2025; it "will continue to be available and supported on Fire TV, Fire Tablet, and Fire TV built-in products." For Fire tablets, Amazon accepts HTML5 web apps as hosted apps ("assets are hosted on a web server"), packaged apps or hybrid apps, running on a web runtime "Built on the open-source Chromium project". Amazon's web app documentation doesn't describe service worker or manifest support, so test offline behavior and installation on a real Fire device before you submit.
  • Huawei AppGallery. Huawei devices sold without Google services don't have Chrome by default. A TWA submitted to AppGallery falls back to a Custom Tab or WebView unless the user has a TWA-capable browser. Huawei's own lightweight format, Quick Apps, is a separate technology, not a PWA package.
  • ChromeOS. Chromebooks run Android apps from Google Play, so a Play-listed TWA also reaches ChromeOS. Bubblewrap's --chromeosonly flag builds a package that installs only on ChromeOS, and ChromeOS supports Play Billing through the Digital Goods API as well.
  • Store.app. An independent web app directory. PWABuilder's documentation describes it as "an open web app store" that "is free to use", with ratings and reviews, and walks through listing a PWA: request developer access, create a listing slug, and claim your domain. Your manifest needs name, short_name, icons, start_url and description. There's no package: the listing leads users to your PWA, which the browser installs.

Keeping one codebase across stores

The same web deployment serves browser installs, TWAs, Store PWAs and iOS wrappers. Your code needs to know which one it's running in, mainly to pick a compliant payment flow and to show the right "rate us" or install UI.

distribution-channel.js
// Detects how the current session was launched and persists it for the tab's lifetime,
// because the launch signals (referrer, first-load cookie) are only present at start.

const KEY = "distribution-channel";

function detect() {
  const ref = document.referrer || "";

  // Trusted Web Activity: the first navigation carries android-app://<package>/.
  if (ref.startsWith("android-app://")) {
    const pkg = ref.slice("android-app://".length).split("/")[0];
    return { channel: "play-twa", androidPackage: pkg };
  }

  // Microsoft Store PWA: Edge sets this referrer on the first navigation.
  if (ref === "app-info://platform/microsoft-store") {
    return { channel: "microsoft-store" };
  }

  // PWABuilder iOS wrapper sets this cookie.
  if (/(?:^|;\s*)app-platform=iOS(?:%20| )App(?:%20| )Store/.test(document.cookie)) {
    return { channel: "ios-app-store" };
  }

  // Capacitor exposes a global with a native-platform check.
  if (window.Capacitor?.isNativePlatform?.()) {
    return { channel: `capacitor-${window.Capacitor.getPlatform()}` };
  }

  // Your own start_url marker, set in twa-manifest.json or the web manifest.
  const source = new URL(location.href).searchParams.get("source");
  if (source === "twa") return { channel: "play-twa" };

  const standalone =
    matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
  return { channel: standalone ? "browser-install" : "browser-tab" };
}

export function getDistributionChannel() {
  try {
    const saved = sessionStorage.getItem(KEY);
    if (saved) return JSON.parse(saved);
  } catch {
    // sessionStorage can throw in some privacy modes; detection still works.
  }
  const result = detect();
  try {
    sessionStorage.setItem(KEY, JSON.stringify(result));
  } catch {
    /* ignore */
  }
  return result;
}

/**
 * Chooses a checkout for digital goods that is allowed in the current channel.
 * `region` should come from your server (for example, from the store account's country),
 * not from the browser locale.
 */
export function digitalGoodsCheckout({ channel }, region) {
  switch (channel) {
    case "play-twa":
      return region === "US" ? "web-or-play" : "play-billing";
    case "ios-app-store":
    case "capacitor-ios":
      return region === "US" ? "web-link-or-iap" : "in-app-purchase";
    case "microsoft-store":
      return "web"; // allowed for non-game PC products under Store policy 10.8.1
    default:
      return "web";
  }
}

Pair this with Analytics for PWAs so installs and sessions from each store are reported separately. Legal requirements vary by region and change with litigation, so keep the mapping in digitalGoodsCheckout() server-driven in production rather than hard-coded.

Common pitfalls

  • Only your upload key in assetlinks.json. The Play-installed app is signed with Play's app signing key; its fingerprint must be listed or users see browser UI.
  • assetlinks.json behind a redirect or under a subpath. Verification reads https://<exact host>/.well-known/assetlinks.json with no redirects.
  • Letting Bubblewrap's generated project drift. Manual edits to the Android project are overwritten by bubblewrap update. Put changes in twa-manifest.json or maintain the Android project yourself.
  • Missing the annual target-SDK deadline. Play blocks updates that target an old API level, and existing apps disappear for new users on newer Android versions. Schedule a rebuild every summer.
  • A web checkout inside the Play or App Store build. Store payment rules apply to web content in the app. Detect the channel and switch flows.
  • Submitting a bare website to Apple. Guideline 4.2 exists for exactly this case. Plan native features before you plan the submission.
  • Changing the web manifest without updating the Microsoft Store package. Icons, names and handlers in the Store package stay frozen until you submit a new package.
  • Reusing one package name across stores with different signing keys. Users who install from one store and receive updates from another hit signature conflicts. Use a package name per store, and list each in assetlinks.json.
  • Losing the signing key. Keep keystores and passwords in a secret manager and back them up; you need them for every future release.

Further reading

On this site

External references