DEV Community

Cover image for React PWA Install Prompt for Chrome, Edge and iPhone
Serban Dan Rusu for Wingo UI

Posted on Originally published at wingo-ui.com

React PWA Install Prompt for Chrome, Edge and iPhone

TL;DR

A React PWA install prompt needs two paths. In Chrome, Edge and Samsung Internet, catch beforeinstallprompt in a client component the root layout renders, call preventDefault, save the event and call its prompt() from your own Install button; on iPhone and iPad no browser fires that event, so show the Share, then Add to Home Screen steps instead. Ask only after people get value, never inside the installed app, an in-app browser or a sign-up flow, and remember a Not now for a couple of weeks.

A React PWA install prompt has to cover two kinds of browsers. Chrome, Edge and Samsung Internet fire beforeinstallprompt, so your own button can open the browser's install dialog. No browser on iPhone or iPad fires it, so the only way in is Share, then Add to Home Screen, and your app has to show people where that is. The usual bugs are a listener in a component that mounts too late and a banner that asks on the first page view. This tutorial builds both paths in Next.js, then covers when not to ask at all.

Which browsers let a page open the install dialog?

Only Chromium browsers. Everywhere else people install from a browser menu, if at all, and the page can only explain where. As of October 2026, per caniuse, MDN's installability guide and the Firefox 143 release notes:

Browser beforeinstallprompt How people install What your UI should do
Chrome and Edge on desktop yes your button calls prompt(), or the address bar icon an Install button
Chrome and Samsung Internet on Android yes your button, or the browser menu an Install button
Safari on iPhone and iPad, plus Chrome, Edge, Firefox and Orion there since iOS 16.4 no Share, then Add to Home Screen show the steps
Safari 17+ on macOS Sonoma or later no File, then Add to Dock show the steps
Firefox on Android no a home screen shortcut that opens in the browser nothing
Firefox on desktop no none from a manifest; on Windows, Firefox 143+ can pin a site to the taskbar nothing
In-app browsers (Instagram, TikTok, Facebook) no none until the page opens in a real browser nothing

On iPhone, no beforeinstallprompt iOS polyfill can help, because no iOS browser has an install dialog a page can open, and WebKit closed the 2019 request for BeforeInstallPromptEvent (bug 193959) as WONTFIX. On the Chromium side, the Chrome 157 release notes list a Web Install API (navigator.install(), desktop first) and an <install> element, and Chromium Dash schedules Chrome 157 for stable on November 3, 2026. WebKit's standards position on that API is oppose, so it changes nothing on iPhone. Build on beforeinstallprompt today.

What does Chrome need before it fires beforeinstallprompt?

A manifest, HTTPS and some engagement. web.dev's install criteria require an app that is not installed yet, served over HTTPS, with a manifest that has name or short_name, a 192px and a 512px icon, start_url, and a display of standalone, fullscreen, minimal-ui or window-controls-overlay. The person must also have clicked or tapped the page once and spent 30 seconds on it, at any time. That is why your PWA install button can stay hidden on a first visit even with a perfect manifest. In Chrome DevTools, Application, then Manifest lists the installability errors.

A service worker is optional for installability, per MDN. Chrome's post Revisiting Chrome's installability criteria (last updated December 2023) dropped the fetch() handler requirement for the menu install (Chrome 108 on mobile, 112 on desktop) but said the automatic install prompt still depended on one. If you depend on the event, register a service worker with a fetch handler anyway; offline support needs one too.

In the App Router the manifest is a file convention:

// app/manifest.ts
import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    id: "/",
    name: "Northwind CRM",
    short_name: "Northwind",
    start_url: "/",
    display: "standalone",
    background_color: "#ffffff",
    theme_color: "#ffffff",
    icons: [
      { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
      { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
      { src: "/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
    ],
  };
}
Enter fullscreen mode Exit fullscreen mode

How do you capture beforeinstallprompt in React?

In a module-level store that starts listening when the root layout mounts, not in a component's state. The browser fires the event as soon as the page qualifies, often right after load, and an event that fired before your listener existed is gone, so a listener in a settings page that mounts later never hears it. Call preventDefault() to keep Chrome's own mini-infobar away, store the event, and call prompt() from a click: the web.dev codelab calls it in the button's click handler because it must come from a user gesture, and resets the saved event because prompt() works once. The event type is not in lib.dom.d.ts, because it is Chromium-only and non-standard (MDN).

// hooks/use-before-install-prompt.ts
import { useSyncExternalStore } from "react";

type BeforeInstallPromptEvent = Event & {
  prompt: () => Promise<void>;
  userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
};

let deferred: BeforeInstallPromptEvent | null = null;
let listening = false;
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((listener) => listener());

function subscribe(listener: () => void) {
  // attached on the first subscribe, in the browser, and kept for the life of the page
  if (!listening) {
    listening = true;
    window.addEventListener("beforeinstallprompt", (event) => {
      event.preventDefault(); // no mini-infobar: the app decides when to ask
      deferred = event as BeforeInstallPromptEvent;
      notify();
    });
    window.addEventListener("appinstalled", () => {
      deferred = null;
      notify();
    });
  }
  listeners.add(listener);
  return () => {
    listeners.delete(listener);
  };
}

export function useBeforeInstallPrompt() {
  const event = useSyncExternalStore(subscribe, () => deferred, () => null);

  // call it from a click handler
  async function install() {
    const current = deferred;
    if (!current) return "unavailable" as const;
    deferred = null; // prompt() works once per event
    notify();
    await current.prompt();
    const { outcome } = await current.userChoice;
    return outcome;
  }

  return { canInstall: event !== null, install };
}
Enter fullscreen mode Exit fullscreen mode

Call the hook in a client component that the root layout renders, and show your button only while canInstall is true. useSyncExternalStore returns null on the server and during hydration, so the button never causes a hydration mismatch. userChoice reports what happened in the dialog your button opened; appinstalled fires for every install, including one from the address bar. Per web.dev, after a dismissal the browser usually fires a fresh event right after userChoice resolves, which the store picks up.

How do you guide Add to Home Screen on iOS?

Detect iPhone and iPad, skip it inside the installed app and in in-app browsers, and show the steps with the icons people will look for. iPadOS reports a Mac user agent, so check touch points too. The PWA guide in the Next.js docs tests /iPad|iPhone|iPod/ only, which misses current iPads, and its Add to Home Screen button has no handler.

// call it in an effect or a store snapshot, never during the server render
export function getInstallContext() {
  const ua = navigator.userAgent;
  const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
  // Instagram, Facebook, TikTok and Android WebViews have no install path
  const inApp = /FBAN|FBAV|Instagram|musical_ly|TikTok|; wv\)/i.test(ua);
  const standalone =
    window.matchMedia("(display-mode: standalone)").matches ||
    (navigator as Navigator & { standalone?: boolean }).standalone === true;
  return { ios, inApp, standalone };
}
Enter fullscreen mode Exit fullscreen mode

iOS 26 changed two things. WebKit's Safari 26 beta announcement says every website added to the Home Screen now opens as a web app by default, with an Open as Web App switch people can turn off. And in Safari's default Compact tab layout, Share sits behind the More (⋯) button, so older Add to Home Screen iOS instructions miss a tap. App builders flagged it in a Glide community thread in September 2025, and iPhone Life's iOS 26.4 walkthrough adds a View More tap before Add to Home Screen. The Bottom and Top layouts keep Share on the toolbar, so write steps that work in all three.

How does the Wingo UI Install Prompt handle both?

It is one component for both paths. Install Prompt keeps the event in a page-wide store, like the hook above, and adds what the hook skips: platform detection for Chromium, iPhone, iPad and Safari on a Mac, the standalone and in-app checks, a remembered Not now, and the UI. It renders nothing where there is no install path.

Live demo: Install Prompt. The original article embeds an interactive demo here (Wait a second for the banner, then tap Install: on an iPhone there is no browser dialog, so a sheet lists the three steps with the icons to look for. The × hides it for 14 days; Show again brings it back). Try it live on wingo-ui.com or open the Install Prompt docs. Install: npx wingo-ui@latest add install-prompt

The demo forces platform="ios"; in your app the default auto detects it. The banner shows delay milliseconds (3000 by default) after an install becomes possible, never takes focus, and sits above a Bottom Tab Bar and the home indicator through --bottom-bar-offset and the safe area insets. On iPhone, Install opens a Drawer with numbered steps and an arrow toward Safari's share button; from 768px up it becomes a centered dialog. On a Mac, Safari gets the File, then Add to Dock steps inline.

Live demo: Install Prompt. The original article embeds an interactive demo here (Tap Install: the demo plays Chrome's install dialog, so the button spins, then reads Installed with a check and the banner leaves. Show again replays it). Try it live on wingo-ui.com or open the Install Prompt docs. Install: npx wingo-ui@latest add install-prompt

Mount it once, high in the tree. This wrapper also keeps it off the screens where it would interrupt a task, and rewrites the iPhone steps for iOS 26:

// components/app-install-prompt.tsx, rendered by app/layout.tsx after {children}
"use client";

import { usePathname } from "next/navigation";
import { InstallPrompt, useInstallPrompt } from "@/components/ui/install-prompt";

// screens where a banner would interrupt a task
const BUSY = /^\/(welcome|signup|checkout)(\/|$)/;

export function AppInstallPrompt() {
  // starts the beforeinstallprompt listener even when the first page people open is a busy one
  useInstallPrompt();
  const pathname = usePathname();
  if (BUSY.test(pathname)) return null;

  return (
    <InstallPrompt
      appName="Northwind CRM"
      appIcon="/icon-192.png"
      description="Open it right from your home screen, even offline."
      delay={5000}
      dismissDays={30}
      labels={{
        iosSteps: ["Tap Share, or ⋯ and then Share", "Choose Add to Home Screen (under View More if needed)", "Tap Add"],
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

For a settings page, variant="card" places the same prompt inline. For a menu item, useInstallPrompt() returns platform, canInstall, isStandalone and promptInstall(), which resolves accepted, dismissed or unavailable.

When should you not ask people to install?

Ask after people have gotten value, and stay quiet everywhere else. web.dev's install promotion patterns say disruptive promotions hurt usability and engagement, that a sign-up form should not be interrupted, and that a turned-down promotion should stay hidden until something changes, such as a sign-in or a purchase. Skip it:

  • On arrival. Chrome already waits for a tap and 30 seconds; iPhone has no such gate, so your delay is the only one.
  • Inside a task. Sign-up, checkout and onboarding are on the BUSY list above. For a multi-step flow, wait until the last step is done.
  • Inside the installed app. Check display-mode: standalone and navigator.standalone. The component also treats minimal-ui and window-controls-overlay as installed.
  • In an in-app browser. A link opened from Instagram or TikTok has no install path, and the Install Prompt renders nothing there. If much of your traffic arrives that way, the MIT-licensed pwa-install web component added in-app browser detection with guided steps, such as opening the link in Safari, in 0.7.0 (September 2026). Ours does not do that.
  • On top of another banner. One floating message at a time. Fixed install banners queue behind each other, but a cookie banner is yours to sequence.
  • Again right after Not now. The × stores a timestamp in localStorage under storageKey and hides the prompt for dismissDays (14 by default; 0 means this session only).

How do you pair the install prompt with onboarding?

Ask for install after onboarding, and on iPhone ask for notifications inside the installed app. WebKit's iOS 16.4 announcement added Web Push for Home Screen web apps, with the permission request tied to a direct user interaction. The Onboarding Flow asks for notifications and location on its own step, so drop the notification card in a Safari tab:

Live demo: Onboarding Flow. The original article embeds an interactive demo here (Tap Allow on each card: this demo answers as a browser that granted it. Continue stays enabled, so both permissions are optional). Try it live on wingo-ui.com or open the Onboarding Flow docs. Install: npx wingo-ui@latest add onboarding-flow

// app/welcome/page.tsx
"use client";

import { useRouter } from "next/navigation";
import { OnboardingFlow, type OnboardingPermission } from "@/components/blocks/onboarding-flow";
import { useInstallPrompt } from "@/components/ui/install-prompt";

export default function WelcomePage() {
  const router = useRouter();
  const { platform, isStandalone } = useInstallPrompt();
  // web push on iPhone and iPad works in the Home Screen app, so a Safari tab skips that card
  const permissions: OnboardingPermission[] =
    platform === "ios" && !isStandalone ? ["location"] : ["notifications", "location"];

  return (
    <OnboardingFlow
      persistKey="onboarding"
      permissions={permissions}
      onComplete={async (result) => {
        await fetch("/api/onboarding", { method: "POST", body: JSON.stringify(result) });
        // /welcome is on the BUSY list, so the install banner waits for the home route
        router.push("/");
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The block's slides, interests, sample user and labels default to its Acme Market demo content, so pass slides, interests, user and labels for your own app before you ship it. Once people finish, the banner shows after its delay on the home route: they have just set the app up, and no form is competing for the screen. For the rest of the mobile layer, read mobile-first React components, add pull to refresh to the PWA, or browse Mobile UI in React.

What should you install next?

Add the component, then the manifest and the root layout wrapper above. npx wingo-ui@latest add install-prompt copies it with the Button, Drawer and hooks it imports, and installs the npm packages they use (motion, lucide-react, vaul and three Radix primitives). Install Prompt and Onboarding Flow are part of Wingo UI Pro, so sign in first with npx wingo-ui@latest login; the Button and Drawer underneath are free.

Components in this post

  • Install Prompt (Pro): Invites people to install the web app: the browser's install dialog in Chrome and Edge, an Add to Home Screen guide on iPhone and Mac. Install: npx wingo-ui@latest add install-prompt
  • Onboarding Flow (Pro): A marketplace app's first run: welcome slides, a calm ask for notifications and location, a short profile, preferences and a finish. Install: npx wingo-ui@latest add onboarding-flow

FAQ

Does beforeinstallprompt work on iOS?

No. No browser on iPhone or iPad fires it, and WebKit closed the 2019 request to add BeforeInstallPromptEvent (bug 193959) as WONTFIX. People install from the Share menu, in Safari or, since iOS 16.4, in other browsers that added the option such as Chrome and Edge, so the best a page can do is show those steps.

Why is beforeinstallprompt not firing?

Chrome fires it only for an app that is not installed yet, served over HTTPS, with a manifest that has a name, 192px and 512px icons, a start_url and a standalone-like display, and only after the person has tapped the page once and spent 30 seconds on it at some point. It often fires right after load, so a listener that mounts later on another route misses it; Application, then Manifest in Chrome DevTools lists what is missing.

How do I know if my PWA is already installed?

Inside the installed app, matchMedia('(display-mode: standalone)') matches, and on iOS navigator.standalone is true as well. In Chromium, the appinstalled event fires on the page at the moment of the install, whichever button started it.

Can I show the install prompt again after people dismiss it?

Each beforeinstallprompt event can call prompt() once; after a dismissal you wait for the browser to fire a new event, which web.dev says usually comes right after userChoice resolves. Your own banner should also remember Not now for a while; the Wingo UI Install Prompt hides it for 14 days by default.

Is the Wingo UI Install Prompt free?

No. The Install Prompt and the Onboarding Flow block are Pro items, while the Button and the Drawer the prompt builds on are free. With a Pro account signed in through npx wingo-ui@latest login, npx wingo-ui@latest add install-prompt copies it and everything it imports into your project.


This article was originally published on the Wingo UI blog: React PWA Install Prompt for Chrome, Edge and iPhone. The original has the live, interactive demos and is kept up to date.

Top comments (0)