# React PWA Install Prompt for Chrome, Edge and iPhone

> Build a React PWA install prompt in Next.js: catch beforeinstallprompt in Chrome and Edge, show Add to Home Screen steps on iPhone, and know when not to ask.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-pwa-install-prompt

## 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](https://caniuse.com/mdn-api_beforeinstallpromptevent), MDN's [installability guide](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable) and the [Firefox 143 release notes](https://www.firefox.com/en-US/firefox/143.0/releasenotes/):

| 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](https://bugs.webkit.org/show_bug.cgi?id=193959)) as WONTFIX. On the Chromium side, the [Chrome 157 release notes](https://chromestatus.com/release-notes/157) list a [Web Install API](https://chromestatus.com/feature/5183481574850560) (`navigator.install()`, desktop first) and an `<install>` element, and [Chromium Dash](https://chromiumdash.appspot.com/schedule) schedules Chrome 157 for stable on November 3, 2026. WebKit's [standards position](https://github.com/WebKit/standards-positions/issues/463) 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](https://web.dev/articles/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](https://developer.chrome.com/blog/update-install-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:

```ts
// 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" },
    ],
  };
}
```

## 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](https://web.dev/articles/codelab-make-installable) 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](https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent)).

```ts
// 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 };
}
```

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](https://web.dev/articles/customize-install), 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](https://nextjs.org/docs/app/guides/progressive-web-apps) tests `/iPad|iPhone|iPod/` only, which misses current iPads, and its Add to Home Screen button has no handler.

```ts
// 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 };
}
```

iOS 26 changed two things. WebKit's [Safari 26 beta announcement](https://webkit.org/blog/16993/news-from-wwdc25-web-technology-coming-this-fall-in-safari-26-beta/) 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](https://community.glideapps.com/t/ios-26-add-to-home-screen-workflow-change/84599) in September 2025, and [iPhone Life's iOS 26.4 walkthrough](https://iphonelife.com/content/tip-day-turn-your-favorite-website-home-screen-icon) 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](https://wingo-ui.com/components/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): 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 at [Install Prompt](https://wingo-ui.com/components/install-prompt) and install it with `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](https://wingo-ui.com/components/bottom-tab-bar) and the home indicator through `--bottom-bar-offset` and the [safe area insets](https://wingo-ui.com/blog/tailwind-safe-area-insets). On iPhone, Install opens a [Drawer](https://wingo-ui.com/components/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): 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 at [Install Prompt](https://wingo-ui.com/components/install-prompt) and install it with `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:

```tsx
// 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"],
      }}
    />
  );
}
```

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](https://web.dev/articles/promote-install) 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](https://github.com/khmyznikov/pwa-install) web component added in-app browser detection with guided steps, such as opening the link in Safari, in [0.7.0](https://github.com/khmyznikov/pwa-install/releases/tag/v0.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](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/) added Web Push for Home Screen web apps, with the permission request tied to a direct user interaction. The [Onboarding Flow](https://wingo-ui.com/components/onboarding-flow) asks for notifications and location on its own step, so drop the notification card in a Safari tab:

> Live demo (Onboarding Flow): Tap Allow on each card: this demo answers as a browser that granted it. Continue stays enabled, so both permissions are optional. Try it at [Onboarding Flow](https://wingo-ui.com/components/onboarding-flow) and install it with `npx wingo-ui@latest add onboarding-flow`.

```tsx
// 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("/");
      }}
    />
  );
}
```

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](https://wingo-ui.com/blog/mobile-first-react-components), add [pull to refresh to the PWA](https://wingo-ui.com/blog/react-pull-to-refresh), or browse [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui).

## 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](https://wingo-ui.com/pricing), so sign in first with `npx wingo-ui@latest login`; the Button and Drawer underneath are free.

## Components in this post

- [Install Prompt](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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.

---

Source: https://wingo-ui.com/blog/react-pwa-install-prompt
