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 on your phone and tap Install.
Here's what it looks like, start to finish -- an ordinary website becoming an installed app:
1. Custom in-app Install App button in the Show Us TV header.
2. Tapping it hands off to the OS: Chrome on Android shows its own native install dialog.
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) -- track every show and movie you watch. React 19 + Vite, served by Hono on Cloudflare Workers.Open Raw Editor (
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:
A web app manifest (
manifest.webmanifest) linked from<head>-- names the app, its icons, and how it launches.A registered service worker (
sw.js) -- the hard prerequisite for installability, and what makes the app survive a dead network.An install button -- capture the browser's
beforeinstallpromptevent 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):
{
"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. Setidexplicitly so the browser doesn't re-key your install ifstart_urlever changes.
Link manifest.webmanifest from inside the <head />:
<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:
// src/web/pwa.ts
export function initPwa() {
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {});
});
}
// ...beforeinstallprompt wiring
}
// 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:
// 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:
// 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:
// 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.jsandindex.htmlwithCache-Control: public, max-age=0, must-revalidateand only hashed/assets/*asimmutable. A stale edge-cachedsw.jsblocks 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:
Capture
beforeinstallpromptearly and stash it. CallpreventDefault()so the browser doesn't show its own mini-infobar, then keep the event -- it's your handle to trigger the prompt later.Show your own button only when you're holding a live event (and not already installed).
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.
Listen for
appinstalledto tear the button down, regardless of how the install happened.Branch for iOS, which never fires the event -- degrade to inline "Share → Add to Home Screen" instructions.
Vanilla version (Open Raw Editor)
// 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:
// 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:
// 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:
// 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:
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:
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):
{!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:
@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:
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=coveron the viewport meta tag:<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:
<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
beforeinstallpromptbefore 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_colorto 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.jsbe 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
screenshotsfield 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_handlersputs 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_targetlets other apps share a link or photo to your app, the same way you'd share something to Instagram or Messages.Long-press shortcuts.
shortcutsgives 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 to track what you're watching, or 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.


