# Building a native app install experience with a PWA

# Turn any website into an installable app -- the easy way

How I make web apps that install to the home screen (Android and iOS), launch full-screen (not in-browser), work offline, and offer a first-class in-app **Install** button.

Want to try it for yourself? Open [**showustv.com**](https://showustv.com) on your phone and tap **Install**.

Here's what it looks like, start to finish -- an ordinary website becoming an installed app:

![](https://cdn.hashnode.com/uploads/covers/5f6c12f1136a1642fa93ae78/103dec74-61d2-4403-a02c-5171ed22e352.png align="center")

*1\. Custom in-app* ***Install App*** *button in the Show Us TV header.*

![](https://cdn.hashnode.com/uploads/covers/5f6c12f1136a1642fa93ae78/2694843c-68fd-48b2-896a-915145a28b63.png align="center")

*2\. Tapping it hands off to the OS: Chrome on Android shows its own native install dialog.*

![](https://cdn.hashnode.com/uploads/covers/5f6c12f1136a1642fa93ae78/c4bb7938-937c-489d-bf51-2d100455292d.png align="center")

*3\. The payoff: Show Us TV on the Android home screen, next to other native apps -- no browser frame, no app store, familiar launch.*

Both examples come from apps I actually run, so you can go poke at the real thing:

*   **Show Us TV** ([`showustv.com`](https://showustv.com)) -- track every show and movie you watch. React 19 + Vite, served by Hono on Cloudflare Workers.
    
*   **Open Raw Editor** ([`open.raweditor.io`](https://open.raweditor.io)) -- a full RAW photo editor that runs entirely in your browser. Vanilla JS + Vite + `vite-plugin-pwa` (Workbox).
    

## The whole thing in three files

An installable PWA is three pieces:

1.  **A web app manifest** (`manifest.webmanifest`) linked from `<head>` -- names the app, its icons, and how it launches.
    
2.  **A registered service worker** (`sw.js`) -- the hard prerequisite for installability, and what makes the app survive a dead network.
    
3.  **An install button** -- capture the browser's `beforeinstallprompt` event and wire it up yourself.
    

Everything below is the detail behind those three lines, plus the standalone-mode polish that makes an installed app stop feeling like a browser tab.

## What "native install experience" actually means

So this is the cool part, I can create a web app, but also give the apps a native feel, without having to build a native app and completely bypass all that app store publishing nonsense.

Four things a user notices:

*   It's a real app on their phone home screen, with my own custom icon
    
*   It opens full-screen, no URL bar, has that native app feel
    
*   There's an obvious "Install" button
    
*   It still works offline (airplane mode)
    

## The manifest describes the PWA to the phone

The manifest is what tells the browser this is an "installable app." The browser reads it to determine what icon/name/window to use.

**Show Us TV** ships it as a static file (`manifest.webmanifest`):

```json
{
  "name": "Show Us TV",
  "short_name": "Show Us TV",
  "description": "Track every show and movie you watch...",
  "id": "/",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "background_color": "#0f1218",
  "theme_color": "#0f1218",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}
```

The fields that carry the "native" feel:

*   `display: "standalone"` -- launch in its own window with no browser UI. This is the single most important line.
    
*   `background_color` **+** `theme_color` -- together they render the launch splash screen (background) and tint the OS status / title bar (theme). Match them to your app shell so the transition from splash to app is seamless.
    
*   `id` **/** `start_url` **/** `scope` -- the app's stable identity and boundaries. Set `id` explicitly so the browser doesn't re-key your install if `start_url` ever changes.
    

Link `manifest.webmanifest` from inside the `<head />`:

```html
<link rel="manifest" href="/manifest.webmanifest" />
```

## A service worker makes the PWA installable (and offline)

A registered service worker is a hard prerequisite for installability, and it's what makes the app survive a dead network. There are two ways to get one; both apps demonstrate one each.

### Path A -- Hand-rolled (Show Us TV)

Just a static `sw.js` and a tiny registration. Registration runs at boot, *before React renders*, because `beforeinstallprompt` can fire before the app mounts:

```ts
// src/web/pwa.ts
export function initPwa() {
  if ("serviceWorker" in navigator) {
    window.addEventListener("load", () => {
      navigator.serviceWorker.register("/sw.js").catch(() => {});
    });
  }
  // ...beforeinstallprompt wiring
}
```

```tsx
// src/web/main.tsx -- before render
initPwa();
```

The worker itself uses versioned caches and a **network-first-on-an-app-shell-key** strategy so any route boots offline, with cache-first for content-hashed assets:

```js
// src/web/public/sw.js
const VERSION = "v1";
const STATIC_CACHE = `static-${VERSION}`;
// ...
if (req.mode === "navigate") {
  event.respondWith(shellNetworkFirst(req));        // any route boots offline
} else if (url.pathname.startsWith("/assets/")) {
  event.respondWith(cacheFirst(req, STATIC_CACHE)); // content-hashed, immutable
} else {
  event.respondWith(networkFirst(req, STATIC_CACHE)); // manifest, icons
}
```

The clever bit: because `sw.js` is static but asset filenames change every build, there's no build-time precache manifest. Install fetches `/` and caches whatever that HTML references; later builds get picked up by the runtime navigation handler. `skipWaiting()` + `clients.claim()` make new workers take over promptly.

### Path B -- Generated by Workbox (Open Raw Editor)

`vite-plugin-pwa` generates the worker and a precache manifest from a glob. The interesting choices are about *update control*:

```js
// vite.config.js
VitePWA({
  // "prompt": a new worker installs but waits until the user accepts an
  // in-app update notice, instead of silently swapping assets mid-session.
  registerType: "prompt",
  injectRegister: false,       // the app registers the SW itself
  manifest: { /* the manifest from earlier */ },
  workbox: {
    globPatterns: ["**/*.{html,js,css,wasm}"],
    clientsClaim: true,        // no skipWaiting -- see below
    // libraw wasm is ~1.4MB; Workbox silently skips files over its 2MB
    // default, which would break offline without warning if it grows.
    maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
  },
}),
```

Because `registerType` is `prompt` (not `autoUpdate`), the app owns the update flow. It registers via the plugin's virtual module and shows a banner when a new worker is waiting:

```js
// src/pwa.js
import { registerSW } from "virtual:pwa-register";

const updateSW = registerSW({
  immediate: true,
  onNeedRefresh: showUpdateBanner,
  onRegisteredSW(_url, registration) {
    // Mobile browsers resume a backgrounded PWA without re-fetching the SW,
    // so poll for updates on an interval and on tab re-focus.
    const check = () => void registration.update();
    setInterval(check, 60 * 60 * 1000);
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "visible") check();
    });
  },
});
// Banner's "Update" button -> updateSW() posts SKIP_WAITING, page reloads.
```

> Serving gotcha: whichever path, serve `sw.js` and `index.html` with `Cache-Control: public, max-age=0, must-revalidate` and only hashed `/assets/*` as `immutable`. A stale edge-cached `sw.js` blocks every future update. Open RAW enforces this in its Hono server; Show Us TV relies on Cloudflare's SPA asset handling.

## The install prompt (the actual "native" moment)

This is the heart of it. By default Chromium hides installation behind a tiny address-bar icon most users never notice, and Safari has no prompt at all. To get a real install button you have to intercept the browser's event and drive it yourself.

The recipe, shared by both apps:

1.  **Capture** `beforeinstallprompt` **early and stash it.** Call `preventDefault()` so the browser doesn't show its own mini-infobar, then keep the event -- it's your handle to trigger the prompt later.
    
2.  **Show your own button** only when you're holding a live event (and not already installed).
    
3.  **On click, fire the stored event.** It's *single-use*: once prompted, discard it and hide the button until the browser hands you a fresh one.
    
4.  **Listen for** `appinstalled` to tear the button down, regardless of how the install happened.
    
5.  **Branch for iOS**, which never fires the event -- degrade to inline "Share → Add to Home Screen" instructions.
    

### Vanilla version (Open Raw Editor)

```js
// src/pwa.js
export function initInstallPrompt(container) {
  if (isStandalone()) return;               // already installed, nothing to do

  /** @type {BeforeInstallPromptEvent | null} */
  let deferredPrompt = null;

  window.addEventListener("beforeinstallprompt", (e) => {
    e.preventDefault();
    deferredPrompt = e;
    section.hidden = false;                 // reveal our Install section
  });

  // Fires however the install happened (our button, the browser's own button...).
  window.addEventListener("appinstalled", () => {
    deferredPrompt = null;
    section.remove();
  });

  if (isIos()) {                            // Safari never fires the event
    btn.textContent = "Add to Home Screen";
    hint.textContent = "In Safari: tap the Share button, then “Add to Home Screen”.";
    btn.addEventListener("click", () => { hint.hidden = !hint.hidden; });
    section.hidden = false;
    return;
  }

  btn.addEventListener("click", () => {
    if (!deferredPrompt) return;
    const prompt = deferredPrompt;
    deferredPrompt = null;                  // single-use; hide until a fresh one
    section.hidden = true;
    void prompt.prompt();                   // shows the native install dialog
  });
}
```

### React version (Show Us TV)

Same logic, but the deferred prompt lives in a module-level singleton exposed to React through `useSyncExternalStore` -- no context provider needed, and it survives the fact that the event may fire before React mounts:

```ts
// src/web/pwa.ts
let deferredPrompt: BeforeInstallPromptEvent | null = null;
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((l) => l());

window.addEventListener("beforeinstallprompt", (e) => {
  e.preventDefault();
  deferredPrompt = e as BeforeInstallPromptEvent;
  notify();
});
window.addEventListener("appinstalled", () => {
  deferredPrompt = null;
  notify();
});

export function useInstallPrompt() {
  const canPrompt = useSyncExternalStore(subscribe, getSnapshot);
  const ios = !canPrompt && isIos();
  return {
    available: !isStandalone() && (canPrompt || ios),
    ios,
    install: () => {
      if (!deferredPrompt) return;
      const prompt = deferredPrompt;
      deferredPrompt = null;   // single-use
      notify();
      void prompt.prompt();
    },
  };
}
```

The component just reads the hook and renders nothing when unavailable -- which keeps the button out of the way on desktop browsers that can't install and inside the installed app itself:

```tsx
// src/web/components/install.tsx
export function InstallAppButton({ buttonClass = "btn" }) {
  const { available, ios, install } = useInstallPrompt();
  const [showHint, setShowHint] = useState(false);
  if (!available) return null;

  if (ios) return (
    <div className="install-app">
      <button className={buttonClass} onClick={() => setShowHint(v => !v)}>
        Add to Home Screen
      </button>
      {showHint && <p className="install-hint">In Safari: tap Share, then "Add to Home Screen".</p>}
    </div>
  );

  return (
    <div className="install-app">
      <button className={buttonClass} onClick={install}>Install App</button>
    </div>
  );
}
```

Because it's just a hook, the button can appear in several places -- Show Us TV puts it in the app header (Chromium only), the marketing hero, and the settings page, each with copy that fits the context:

```tsx
// settings.tsx
{install.available && (
  <>
    <h2 className="settings-subtitle">Install app</h2>
    <p className="settings-hint">
      Put Show Us TV on your home screen. It opens full screen, like a native app.
    </p>
    <InstallAppButton buttonClass="btn btn-ghost" />
  </>
)}
```

## Standalone detection and the finishing touches

Once installed, the app should *know* it's installed and behave a little differently. It should never render its own "Install" UI to someone who already installed it.

Both apps use the identical detection helpers. Note the two non-obvious cases:

```ts
export function isStandalone(): boolean {
  return (
    window.matchMedia("(display-mode: standalone)").matches ||
    // iOS Safari exposes installed state here, not via display-mode.
    (navigator as { standalone?: boolean }).standalone === true
  );
}

export function isIos(): boolean {
  return (
    /iphone|ipad|ipod/i.test(navigator.userAgent) ||
    // iPadOS reports itself as macOS, but Macs have no touch points.
    (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
  );
}
```

What standalone detection buys you:

*   **Skip the marketing pitch.** Show Us TV sends installed users straight to Login instead of the landing page. There's no reason to re-sell a product they already installed:
    
    ```tsx
    const loggedOutRoot = isStandalone() ? <Login /> : <Landing />;
    ```
    
*   **Hide web-only chrome.** The Login page's "back to home" close button is hidden in standalone (there's no browser to go "back" to):
    
    ```tsx
    {!isStandalone() && <Link to="/" className="login-close">…</Link>}
    ```
    
*   **Belt-and-braces CSS guard.** Open RAW hides its whole install section via media query, so even if the JS misfires the UI can't appear inside the installed app:
    
    ```css
    @media (display-mode: standalone) {
      .section.section-install { display: none; }
    }
    ```
    
*   **Respect the notch.** Full-screen means your content now owns the status-bar and home-indicator areas. Use safe-area insets so nothing hides under a notch:
    
    ```css
    padding-top: calc(10px + env(safe-area-inset-top));
    padding-bottom: env(safe-area-inset-bottom);   /* e.g. a bottom tab bar */
    ```
    
    This requires `viewport-fit=cover` on the viewport meta tag:
    
    ```html
    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
    ```
    

### Head tags and icons

Keep the head lean. A `theme-color`, the manifest link, and an `apple-touch-icon` are the essentials:

```html
<meta name="theme-color" content="#0F1218" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
```

Both apps deliberately **omit** the legacy `apple-mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style`, and `apple-touch-startup-image` tags, leaning on the manifest and modern iOS behavior instead.

## Lessons and gotchas worth internalizing

*   **Register the SW and start listening for** `beforeinstallprompt` **before your framework renders.** The event can fire during first paint; if no listener exists yet, it's gone.
    
*   **The deferred event is single-use.** After `.prompt()`, throw it away and hide the button until the browser gives you a fresh one.
    
*   **iOS never fires** `beforeinstallprompt`**.** Detect iOS and show manual Share-sheet instructions, or users on the largest install base get no path at all.
    
*   **Match** `background_color`**/**`theme_color` **to your shell** so the launch splash doesn't flash a mismatched color.
    
*   **Ship a maskable icon** or Android will crop your logo's corners off.
    
*   **Never let** `sw.js` **be cached long-term** or you'll lock users on a stale build with no way to update.
    
*   **Guard install UI two ways** -- in JS (`!isStandalone()`) and in CSS (`@media (display-mode: standalone)`).
    

## What both apps skip (natural next steps)

Everything so far gets you an app that installs and feels native. A handful of extra manifest features would take it further, letting the app plug into the phone's operating system the way a store-installed app does. Neither of mine uses these yet, but each is a natural next step:

*   **A richer install dialog.** Add a `screenshots` field and Chrome shows an app-store-style install prompt with preview images, instead of the plain one pictured near the top.
    
*   **Open files straight into the app.** `file_handlers` puts your app in the operating system's "Open with…" menu, so opening a file launches it directly. An obvious fit for Open Raw Editor, which today can only load a photo by drag-and-drop or a file picker inside the page.
    
*   **Show up in the system Share menu.** `share_target` lets other apps share a link or photo *to* your app, the same way you'd share something to Instagram or Messages.
    
*   **Long-press shortcuts.** `shortcuts` gives the home-screen icon a quick-action menu when you long-press it -- jump straight to a specific screen, like "Add a show."
    

**Go try them!** Everything above is live right now: open [**showustv.com**](https://showustv.com) to track what you're watching, or [**open.raweditor.io**](https://open.raweditor.io) to edit a RAW photo entirely in your browser.

Questions, or want to dig into the full working code? Open Raw Editor is open source -- everything from this post (manifest, Workbox config, install prompt, iOS fallback) is in the repo: [**github.com/joelnet/open.raweditor.io**](https://github.com/joelnet/open.raweditor.io).
