Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Build it: screens and apps
  4. React Onboarding Flow: Slides, Permissions, Install
Auf Deutsch lesen
  1. Blog
  2. Build it: screens and apps
  3. React Onboarding Flow: Slides, Permissions, Install
Build it: screens and apps
Build it: screens and apps

React Onboarding Flow: Slides, Permissions, Install

SR
Serban Rusu · Founder of Wingo UI
Oct 16, 2026 · 9 min read

On this page

0%
  1. What should a first-run flow ask, and when?
  2. How do you build the React onboarding flow?
    1. Gate the app on a completed flag
    2. Render the flow with your own content
  3. How do you make welcome slides people can skip?
  4. How do you ask for notifications and location without getting blocked?
  5. How do you ask for notifications later, in context?
  6. What changes for onboarding screens in a PWA?
  7. What should you build next?
  8. FAQ
    1. When should a web app ask for notification permission?
    2. Can I ask for notification permission again after someone blocks it?
    3. How do I show onboarding only once in React?
    4. Should onboarding slides be skippable?
    5. Do iPhone users get web push notifications from a PWA?

TL;DR

A good React onboarding flow keeps the welcome slides short with a visible Skip, then asks for notifications and location on a card of its own that says why. The browser prompt runs only when someone taps Allow, so Not now never spends the one prompt a blocked site cannot get back. Save progress as people go, record completion on the server, and offer the PWA install only after the flow is done.

Published Oct 16, 2026

A React onboarding flow has three jobs: tell people what the app does in a few seconds, get the permissions the app genuinely needs, and save enough about them to make the first screen useful. Many first-run screens fail at the second job. They fire the browser's notification prompt the moment the page loads, people tap Block, and the site can never ask again. This tutorial builds a flow in Next.js that shows skippable welcome slides, asks for notifications and location on its own screen with a reason for each, resumes after a reload, and offers the PWA install once everything is done.

What should a first-run flow ask, and when?

As little as possible, and each thing at the moment it makes sense. Nielsen Norman Group's analysis of mobile-app onboarding (opens in a new tab) (June 2020) recommends skipping onboarding when the interface can teach itself, advises against deck-of-cards tutorials, and says that when you keep one it needs a highly visible Skip. It also says to explain why you want any data and how you will use it.

The browsers set the rules for permissions. As of October 2026:

  • web.dev's Permission UX guide (opens in a new tab) (last updated March 2025) says not to ask for push permission as soon as someone opens the site, recommends a "double permission" pattern where your own dialog comes first, and warns that after a block the app cannot ask again.
  • Firefox 72 made Notification.requestPermission() work only in response to a user gesture such as a click, and rejects the promise otherwise (Mozilla Hacks (opens in a new tab)).
  • Chrome 80 introduced a quieter notification permission UI (opens in a new tab) (January 2020) and enrolls sites with very low acceptance rates in it automatically, until their rates improve.
  • web.dev's user location guide (opens in a new tab) says people distrust sites that ask for their position on page load, and that the request should follow a user gesture.

So good notification permission UX comes down to one pattern: your own card says what the permission is for, Allow calls the browser API from that click, and Not now stores a skip without touching the browser. The browser prompt stays unused, and you can offer it again later in context.

How do you build the React onboarding flow?

Start from the Onboarding Flow block. It runs five steps: welcome slides, permissions, a short profile, preferences and a finish screen. It saves every answer as people go, so a closed tab resumes on the same step.

bash
npx wingo-ui@latest add onboarding-flow install-prompt

The block brings the Onboarding Carousel it uses for the slides, plus the Button, Switch, Combobox and the other parts it imports.

Gate the app on a completed flag

Decide the first run experience on the server. A localStorage flag alone shows the slides again on a new phone or after someone clears site data, so store onboardedAt on the user and redirect from a layout. Put /welcome outside the route group that redirects to it, or the redirect loops.

tsx
// app/(app)/layout.tsx
import type { ReactNode } from "react";
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth"; // your auth helper
export default async function AppLayout({ children }: { children: ReactNode }) {
const user = await getCurrentUser();
if (user && !user.onboardedAt) redirect("/welcome");
return children;
}
tsx
// app/welcome/page.tsx
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
import { WelcomeFlow } from "./welcome-flow";
export default async function WelcomePage() {
const user = await getCurrentUser();
if (!user) redirect("/sign-in");
if (user.onboardedAt) redirect("/");
return <WelcomeFlow userId={user.id} name={user.name} />;
}

Render the flow with your own content

The block ships with the slides, interests and labels of its Acme Market demo. Replace them before you ship. labels is merged shallowly: each top-level key you pass replaces the default whole, so an override of permissions needs both entries in full.

tsx
// app/welcome/welcome-flow.tsx
"use client";
import { useRouter } from "next/navigation";
import {
OnboardingFlow,
type OnboardingInterest,
type OnboardingSlide,
} from "@/components/blocks/onboarding-flow";
const slides: OnboardingSlide[] = [
{
id: "nearby",
title: "Listings near you first",
description: "We sort by distance, so the closest bike shows up on top.",
image: "/onboarding/nearby.svg",
imageAlt: "",
},
{
id: "chat",
title: "Reply from anywhere",
description: "Buyers and sellers talk in the app, on any device.",
image: "/onboarding/chat.svg",
imageAlt: "",
},
];
const interests: OnboardingInterest[] = [
{ id: "bikes", label: "Bikes" },
{ id: "furniture", label: "Furniture" },
{ id: "electronics", label: "Electronics" },
{ id: "kids", label: "Kids" },
{ id: "garden", label: "Garden" },
];
export function WelcomeFlow({ userId, name }: { userId: string; name: string }) {
const router = useRouter();
return (
<OnboardingFlow
user={{ name }}
slides={slides}
interests={interests}
minInterests={2}
permissions={["notifications", "location"]}
persistKey={`onboarding:${userId}`}
// the sample side panel points at /demo/*.jpg, which the install does not copy
art="none"
onUpload={async (blob) => {
const response = await fetch("/api/avatar", { method: "POST", body: blob });
if (!response.ok) throw new Error("Upload failed");
return (await response.json()).url;
}}
labels={{
appName: "Northwind Market",
welcomeHeading: "Welcome to Northwind Market",
permissions: {
notifications: {
title: "Notifications",
reason: "Hear the moment a buyer replies",
unsupported: "This browser cannot show notifications",
},
location: {
title: "Location",
reason: "Sort listings by distance from you",
unsupported: "This browser does not share its location",
},
},
}}
onComplete={async (result) => {
// result: { profile, preferences, permissions }, each permission granted, denied or skipped
const response = await fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(result),
});
if (response.ok) router.replace("/");
}}
/>
);
}

A promise returned from onComplete spins the Go to Home button until it settles. Your route handler sets onboardedAt and stores the answers. The finish screen's tips live in labels.tips and the step intros in keys like permissionsDescription, so rewrite those too.

Three pitfalls before you ship:

  • The side panel. From 1024px the block shows a picture beside the card. Custom slides replace it on the welcome step only. The later steps keep the sample photos (paths under /demo/, which add does not copy) and marketplace vignettes such as a "Michael Reed replied" notification. Pass art="none", as above, unless you also replace labels.art and those vignette labels.
  • The profile photo. Without onUpload, the default handler pretends to upload for 900 ms and keeps the cropped photo in profile.avatar as a data URL, so the JSON you POST can carry a whole image. The onUpload above sends the blob to your own route and resolves its URL.
  • The city. The city search on the profile step, and the city a granted location fills in, come from the block's Romanian city list (lib/ro-locations). For another market, swap that data or leave the city out of your saved profile.

How do you make welcome slides people can skip?

Keep them short and let people leave from the first one. On its own, the React onboarding carousel works like this: a swipe or Next advances, the dots jump, Left and Right move with the keyboard, and Skip sits in the top corner on every slide but the last.

Swipe or press Continue through the four slides. Skip jumps to the last one, and Get started ends the tour and shows the app behind it
$ npx wingo-ui@latest add onboarding-carousel
ProOnboarding Carousel docs

Inside the flow, Skip on the slides goes straight to the permissions step. If you use the carousel alone, for an app without sign-in, pass persistKey so a finish is remembered on that device, and set skipTo="complete": Skip then ends the tour at once, instead of landing on the last slide and asking for one more tap.

Mount it as a modal over the home screen, not on a route of its own:

tsx
// components/first-run.tsx, rendered by the home page
"use client";
import { OnboardingCarousel, type OnboardingSlide } from "@/components/ui/onboarding-carousel";
export function FirstRun({ slides }: { slides: OnboardingSlide[] }) {
return (
<OnboardingCarousel
modal
defaultOpen
slides={slides}
persistKey="onboarding-v1"
skipTo="complete"
/>
);
}

The modal waits until storage is read before it opens, so a returning visitor never sees a frame of it. It slides up full screen with safe-area padding on a phone, opens as a centered card on desktop, and Escape or a click outside counts as Skip. The inline layouts behave differently: the server renders them before storage is read, so a returning visitor sees the slides for a moment, they fade out, and onComplete does not run again. A carousel on its own /intro route that redirects in onComplete leaves that visitor on an empty page.

To show the slides again while testing, call writePersisted("onboarding-v1", null) from @/hooks/use-persisted-state. Changing the key to onboarding-v2 shows a new tour to everyone once.

How do you ask for notifications and location without getting blocked?

Give each permission a card with a reason, and call the browser only from Allow. That is what the permissions step does: each card shows a title and a one-line reason, Allow calls Notification.requestPermission() or navigator.geolocation.getCurrentPosition() from the tap, and Not now marks it skipped. Continue is never disabled, so both permissions stay optional.

Tap Allow on the notification card: the demo answers like a browser that granted it. Tap Not now on location, then Allow anyway to change your mind
$ npx wingo-ui@latest add onboarding-flow
ProOnboarding Flow docs

The demo passes simulatePermissions, which answers granted after 600 ms. In your app leave it off and the cards report what the browser said. A card shows Allowed, Denied with a pointer to settings, Skipped with an Allow anyway link, or a short note when the browser has no such API. A permission granted on an earlier visit shows as Allowed on mount, and a granted location fills the profile's city. onComplete receives each answer as granted, denied or skipped, so you know whom to ask again later. A browser without the API reports denied. skipped covers both Not now and a browser prompt closed without an answer, so in either case you may ask again later.

Two decisions are yours. First, drop a permission the app cannot justify yet: permissions={["location"]} asks for location only, and [] skips the step. If notifications only pay off after someone posts a listing, ask then. Second, on iPhone and iPad, Web Push works only in the installed Home Screen app (WebKit, iOS 16.4 (opens in a new tab)). The React PWA install prompt post shows how to drop the notification card in a Safari tab with useInstallPrompt().

How do you ask for notifications later, in context?

With the same pattern outside onboarding. Show a card at the moment the value is obvious, such as right after someone sends their first message, and only while the browser can still ask:

tsx
// components/reply-alerts-card.tsx
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
export function ReplyAlertsCard({ onAnswer }: { onAnswer?: (answer: NotificationPermission) => void }) {
const [canAsk, setCanAsk] = useState(false);
const [asking, setAsking] = useState(false);
useEffect(() => {
// "default" means the browser has not asked yet; granted and denied are final here
setCanAsk("Notification" in window && Notification.permission === "default");
}, []);
if (!canAsk) return null;
async function allow() {
setAsking(true);
// called from the click: Firefox rejects it otherwise
const answer = await Notification.requestPermission();
setAsking(false);
setCanAsk(false);
onAnswer?.(answer);
}
return (
<div className="flex flex-col gap-3 rounded-2xl bg-card p-4 shadow-raised sm:flex-row sm:items-center">
<p className="flex-1 text-sm text-muted-foreground">
Want to know when Emma replies? We only notify you about messages.
</p>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => setCanAsk(false)}>
Not now
</Button>
<Button size="sm" loading={asking} onClick={allow}>
Turn on
</Button>
</div>
</div>
);
}

The Button keeps its width while loading swaps the label for a spinner. Remember a Not now in your own storage so the card does not return on every page.

What changes for onboarding screens in a PWA?

The same screens, with two differences: iPhone push needs the installed app, and the install offer itself belongs after onboarding.

Onboarding screensPWA (installed)Browser tab
Welcome slidesonce, full screen with safe area paddingonce, same layout
Notifications cardaskon iPhone and iPad, leave it out
Location cardaskask
Install offernothing, it is already installedafter the flow, on the home route

Mount the Install Prompt once in the root layout and hide it while the flow runs. Chrome fires beforeinstallprompt as soon as the page qualifies, often while someone is still on the slides, and a listener added after that misses it. useInstallPrompt() starts the shared listener, keeps the event and calls preventDefault() on it, so the banner can still offer Install later:

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";
export function AppInstallPrompt() {
// keeps listening for the install event while /welcome is open
useInstallPrompt();
const pathname = usePathname();
if (pathname.startsWith("/welcome")) return null;
return (
<InstallPrompt
appName="Northwind Market"
appIcon="/icon-192.png"
description="Open it from your home screen, even offline."
delay={5000}
/>
);
}
Tap Install: the demo plays Chrome's install dialog, so the button spins, then shows Installed with a check and the banner leaves
$ npx wingo-ui@latest add install-prompt
ProInstall Prompt docs

When the flow redirects to /, the banner waits delay milliseconds, then slides up. People have just set the app up and no form competes for the screen. Inside the installed app it renders nothing, and Not now hides it for 14 days by default (dismissDays).

What should you build next?

Install the block and replace its content: npx wingo-ui@latest add onboarding-flow install-prompt. The Onboarding Flow, the Onboarding Carousel and the Install Prompt are part of Wingo UI Pro; the Button, Switch and Alert they use are free. For a longer form after sign-up, the React multi step form guide covers validation per step and saved drafts, and the Steps component gives it a progress header. The rest of the app's screens are in the Next.js admin dashboard tutorial and the other step by step builds.

Components in this post

  • Onboarding Flow

    A React onboarding flow for a marketplace app: welcome slides, notification and location asks, a short profile, preferences and a finish.

    Pro
  • Onboarding Carousel

    A React onboarding carousel of first-run slides for an app or PWA: swipe or tap Next, follow the dots, Skip any time, then Get started.

    Pro
  • Install Prompt

    A React PWA install prompt: the browser's install dialog in Chrome and Edge, and an Add to Home Screen guide on iPhone and Mac.

    Pro

FAQ

When should a web app ask for notification permission?

When people can see what the notifications are for, and always from a click. web.dev recommends never asking as soon as the site opens, and Firefox 72 and later reject Notification.requestPermission() calls that do not come from a user gesture.

Can I ask for notification permission again after someone blocks it?

No. Once the browser prompt is answered with Block, Notification.permission stays denied and only the person can change it in the browser's site settings. That is why an Allow or Not now card of your own should come first: Not now on your card leaves the browser prompt unused.

How do I show onboarding only once in React?

Store the finish. On one device a localStorage flag is enough (the Onboarding Carousel does it with persistKey); for signed-in people, save a completed date on the user record in onComplete and redirect from the server when it is missing, so a new phone does not see the slides again.

Should onboarding slides be skippable?

Yes. Nielsen Norman Group advises against deck-of-cards onboarding in most apps and says any that remains needs a highly visible Skip option. Keep it to three slides or fewer, one idea each.

Do iPhone users get web push notifications from a PWA?

Only from the installed Home Screen app. WebKit added Web Push for Home Screen web apps in iOS 16.4, so in a Safari tab you should leave the notification card out of onboarding and ask after people install.

  • Onboarding
  • Permissions
  • PWA
  • React
  • Next.js

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

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.

SRSerban Rusu·Oct 9, 2026·9 min read
Component guides

Embla Carousel React: Accessible Slides, No Layout Shift

Build an embla carousel react component for photos, listings, testimonials and onboarding, with keyboard controls, pausable autoplay and no layout shift.

SRSerban Rusu·Oct 19, 2026·9 min read
Component guides

React Product Tour: Spotlight, Phone Dock, Hotspots

Build a React product tour that spotlights the real UI, docks at the bottom on phones and remembers who finished it, plus when a hotspot is the better choice.

SRSerban Rusu·Oct 19, 2026·9 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI