Pages & Navigation
Build multi-page paywalls, onboardings, and funnels with file-based pages and a stack router, no network between steps, no loading spinners.
Multi-page paywalls, onboarding quizzes, and funnels are built from file-based pages and a stack router. Moving between pages never touches the network. The whole flow ships together, so there's no page load, no spinner, and no screen that never arrives.
Add pages
Every .tsx file in app/ is a page; directories nest the name:
app/
├── index.tsx "index" — where a flow starts, unless layout.tsx says otherwise
├── plans.tsx "plans"
├── layout.tsx wraps every page (the one reserved name), may export `entry`
└── goals/
├── index.tsx "goals"
└── setup.tsx "goals/setup"File names are lowercase-kebab, and each page default-exports a component. Components that aren't pages go in components/, not app/. A stray file there is a warning in dev and blocks a push.
Only the top-level layout.tsx is special. A nested goals/layout.tsx would become a page named goals/layout. There are no nested layouts.
Navigate
import { useRouter } from "superwall/navigation";
const router = useRouter();
router.push("goals/setup"); // forward
router.push("plans", { transition: "fade" }); // with a transition
router.push("checkout", { teleport: true }); // in an iOS app, continue on the web (see Teleport)
router.replace("terms"); // swap the current page
router.back(); // one step back
router.canGoBack(); // anything to go back to?
router.dismiss(2); // back two steps
router.dismissAll(); // back to the first page
router.dismissTo("goals"); // unwind to it (replaces current if not in the stack)
router.reset(["index", "goals", "plans"]); // mid-flow: the whole stack at once, no transition
router.name; // current page
router.depth; // pages underneath (index = 0)If you've used expo-router, this is the same shape, minus navigate/setParams, plus name, depth and reset. Page names autocomplete and reject typos, thanks to the generated superwall.d.ts, one more reason to commit it.
A few rules make navigation feel right:
- Closing the paywall is
useActions().close(), not navigation. The stack is for moving within the flow; closing hands control back to your app. See Actions. - Pages you navigate away from stay alive. Going back restores a page exactly as it was left, scroll position and state included. A covered page can't be clicked or focused;
useIsFocused()tells a page it's covered so it can pause video or timers. - There is no declared page order. Any page can push any page, which is exactly what makes branching flows possible.
- Page views are tracked for you. Every navigation reports analytics automatically; there's nothing to instrument.
Choose where a flow opens
A flow opens on index. To open somewhere else, export entry from app/layout.tsx, the surface's one root layout (there are no nested layouts, and an entry exported anywhere else is ignored). It receives what the app has told the paywall (user attributes, device, and the placement's params) and returns the stack to open on, bottom to top, or a single page name. The function is yours: the framework only calls it, so any logic that turns those variables into page names works.
// app/layout.tsx
import { parseAsStringEnum, readAttribute } from "superwall/hooks";
import type { Entry } from "superwall/navigation";
const goals = parseAsStringEnum(["lose", "build", "endure"]);
export const entry: Entry = ({ user, params }) => {
if (params.source === "settings") return "plans";
return readAttribute(user, "onboarding_goal", goals) ? ["index", "goal", "level"] : "index";
};- It runs once per load of the paywall, before anything renders. See When
entryruns. - The flow opens on that stack. Nothing animates, pages outside the stack never mount (here
indexonly mounts because it is in the stack), back walks down it with the usual transitions, anddismissAll()returns to its first page. Onepage_view(entry) is reported, for the page on top. - Page names are typed, so a typo is a compile error. A name the surface doesn't have, or an
entrythat throws, opens onindexand logs to the console instead of breaking the paywall.[]opens onindextoo. - Read attributes through a parser.
readAttribute(user, key, parser)(fromsuperwall/hooks) reads one the wayuseUserAttributedoes, so a stale or foreign value isnullrather than a surprise. For a flow,replayStackbuilds the whole stack. - A stack in the URL wins. On the web a
?sw_nav=stack (a reload, browser history, the return from hosted checkout) decides where the flow opens, andentryisn't called.
entry decides where a flow opens. To jump somewhere mid-flow, router.reset(stack) replaces the whole stack the same way.
Some uses beyond resuming an onboarding:
- Returning users skip the welcome.
readAttribute(user, "has_seen_welcome", parseAsBoolean) ? "plans" : "index". - One surface, several doors. Open on
plansfrom a settings placement and onindexfrom onboarding, byparams.sourceor any other placement parameter. - Per platform or device. A shorter flow on iPad (
device.interfaceType), or a page that only makes sense on one platform. - A flow that already finished. Open straight on the last page when its answers are complete.
When entry runs
- The paywall loads. In an app the SDK loads it into its webview, often before it is shown: the SDK preloads paywalls when it starts, so loading can happen long before the user opens one.
- The host sends its state. User attributes, device, the placement's params and products arrive in the SDK's answer to the paywall's first message. Until then a flow with
entryrenders nothing (one withoutentryrenders at once and never waits); if the host never answers, it renders after three seconds without that state. In an app the SDK keeps its own loading view up for this, so the wait isn't seen. With no host at all (opened directly in a browser) there is nothing to wait for. entryruns, once. Its stack mounts in one go and that is the first frame anyone sees, with no transition. React's development StrictMode can call it twice, so keep it a pure function of its input.- The paywall is shown. On iOS the SDK reveals the webview only after step 2, so a user never sees a frame that doesn't have the state.
What that means in practice:
- Showing the same loaded paywall again doesn't call
entryagain. The iOS SDK keeps the loaded webview with its pages, so a user who closed on question four and opens it again in the same app session is still on question four. The next time the SDK loads the paywall (a new launch, or after it refreshes the paywall)entrydecides again, from the attributes saved by then. - Attributes the app sets after the paywall loaded aren't seen by
entry. If a decision depends on something the app sets late, set it before the SDK loads the paywall, or move the decision into the page (useUserAttributere-renders when the attribute changes). entryonly picks pages. It has no access to hooks and must not do anything: no attribute writes, nofetch, no analytics. Work that should happen when a flow opens belongs in an effect inlayout.tsx, which runs once the first page has mounted:
export default function Layout({ children }: PropsWithChildren) {
const [, setOpened] = useUserAttribute("onboarding_opened", parseAsBoolean);
useEffect(() => {
setOpened(true);
}, [setOpened]);
return <>{children}</>;
}Pass state between pages
Navigation carries no params, on purpose. Cross-page state has three homes:
layout.tsx stays mounted for the whole flow, React state or context there is visible to every page. Use it for UI state that only matters while the paywall is open:
export default function Layout({ children }: PropsWithChildren) {
return <div className="shell"><Chrome />{children}</div>;
}User attributes, for anything the flow asks the user. Layout state, a module variable and the router all go away when the paywall closes; an attribute is persisted by the host SDK across presentations and launches. useUserAttribute reads and writes one as typed state, with the same parsers as useQueryState, and re-renders at once. Save the answer the moment it is given, before moving on:
import { parseAsStringEnum, useUserAttribute } from "superwall/hooks";
const goals = parseAsStringEnum(["lose", "build", "endure"]);
const [goal, setGoal] = useUserAttribute("onboarding_goal", goals); // Goal | null
const choose = (value: Goal) => {
haptics.selection();
setGoal(value);
router.push("level");
};
<button aria-pressed={goal === "build"} onClick={() => choose("build")}>Every page that reads onboarding_goal gets the same typed value, null while it is unanswered, so a revisited page never crashes. Resume an onboarding builds on this.
The URL, for flows on the web, and on a web funnel this is not one option among several. It is the rule. Neither React home survives a reload, and an in-app browser (Instagram, TikTok) hands only the link to Safari when someone taps "open in browser". Its storage stays behind. So a surface with web checkout keeps its state in the page URL: the route stack goes in automatically, and every answer, selection and input is kept with useQueryState, never useState:
import { parseAsStringEnum, useQueryState } from "superwall/navigation";
const [goal, setGoal] = useQueryState("goal", parseAsStringEnum(["focus", "habit"]));
setGoal("focus"); // ?goal=focus — and the plan page reads the same hook
router.push("plan");The API is nuqs's, so the parsers read the same: parseAsString, parseAsInteger, parseAsFloat, parseAsBoolean, parseAsStringEnum, parseAsArrayOf, createParser, each with .withDefault() and .withOptions({ history, clearOnDefault }). Any link then resumes the flow on the same step with the same answers, after a reload, in the OS browser, or back from hosted checkout, and the browser's back button is router.back().
Web Funnels has the full treatment, multi choice, inputs, branching, the URL budget. Three rules keep it honest:
- Only what a page asks for is persisted. The framework never decides what an answer is. Keys starting with
sw_, plusplatformandtransport, are reserved, the hook throws on them. The route stack is one of those reserved keys,sw_nav— a comma-joined list of route names (?sw_nav=offer), validated against the surface's routes. It decides where a surface opens even whenqueryStateis off, so a link, a QR code or a screenshot job can enter a flow at any page; withqueryStateoff it is read once and never written back, so navigation from there on is plain state. - Mind the URL budget. About 2 kB is safe across every app and share sheet, keep keys short and values enumerable, and keep anything personal out of a URL.
- The same code runs natively. In an SDK webview there is no URL bar, so
useQueryStateis plain state shared across pages. The flow reads identically everywhere.definePaywall({ queryState: true | false })overrides the checkout default on web builds; a native host forces it off regardless.
Resume an onboarding where the user left off
A user who closes an onboarding on question four should land on question four the next time it opens, not on page one. Four rules get there:
- Save every answer as a user attribute the moment it is given, with
useUserAttribute, as above. Saving on a final "Continue" loses everything for the user who quits halfway. - Store the answers, never the position. No
onboarding_stepor page index: which answers exist already says where the user is, and stays right when you reorder, add, branch or remove a question. - Keep the flow in one
nextfunction. It says where each page leads, from the answers it branches on. The buttons push what it returns. - Replay it in
entry.replayStackwalksnextfrom the first page while each page is answered, so the flow opens on the first unanswered question of the branch the user took.
// components/flow.ts
import { parseAsStringEnum, readAttribute, type UserAttributes } from "superwall/hooks";
import type { RouteName } from "superwall/navigation";
const goals = parseAsStringEnum(["lose", "build", "endure"]);
const levels = parseAsStringEnum(["new", "returning", "regular"]);
const times = parseAsStringEnum(["15", "30", "45"]);
const answersOf = (user: UserAttributes) => ({
goal: readAttribute(user, "onboarding_goal", goals),
level: readAttribute(user, "onboarding_level", levels),
time: readAttribute(user, "onboarding_time", times),
});
// Losing fat skips the level question.
export const next = (route: RouteName, user: UserAttributes): RouteName | undefined => {
const { goal } = answersOf(user);
if (route === "index") return "goal";
if (route === "goal") return goal === "lose" ? "time" : "level";
if (route === "level") return "time";
if (route === "time") return "plan";
return undefined;
};
// The intro counts as passed once the first question is answered.
export const answered = (route: RouteName, user: UserAttributes): boolean => {
const { goal, level, time } = answersOf(user);
if (route === "index" || route === "goal") return goal !== null;
if (route === "level") return level !== null;
if (route === "time") return time !== null;
return false;
};// app/goal.tsx: save, then go where the flow says, given the answer just saved
const user = useUser();
const [goal, setGoal] = useUserAttribute("onboarding_goal", goals);
const choose = (value: Goal) => {
haptics.selection();
setGoal(value);
const to = next("goal", { ...user, onboarding_goal: value });
if (to) router.push(to);
};// app/layout.tsx
import { replayStack, type Entry } from "superwall/navigation";
import { answered, next } from "../components/flow";
export const entry: Entry = ({ user }) =>
replayStack("index", (route) => next(route, user), (route) => answered(route, user));app/index.tsx stays an ordinary intro page, with no effect and no redirect.
- The attributes are there when
entryruns. The paywall waits for the host's state before rendering (Whenentryruns), so the answers saved last time are inuser.nextandansweredare your own code;replayStackonly walks them. - Branches resume on the branch. A user who chose "lose" reopens on
index → goal → time, without the level question; one who chose "build" onindex → goal → level → time. Change an answer and the replay follows the new branch, even if an answer from the old one is still stored. - Closing and reopening in the same session needs nothing. The iOS SDK keeps the loaded paywall, so it reopens on the page the user left.
entrycovers the next load, after a relaunch. - The resumed question is the first frame. No intro flash and no transition.
- The whole stack, not only the next question. The stack is the one the user would have built by tapping through: back steps to the previous question, with its answer selected and the usual transition, and a "Start over" button calling
dismissAll()returns to the intro to answer again, which overwrites the old attributes. When nothing is answered the stack is just["index"]. - It can't loop.
replayStackstops where it is, with a console warning, at a pagenextreturns twice, an empty or non-string name, a name the surface doesn't have, or past 100 pages. A throw fromnextoransweredopens onindex. - Analytics see where the user landed. One
page_view(entry) for the resumed question, none for the pages under it.
Don't make each question page skip itself whenever its attribute exists. It looks like the same thing, but the user can then never go back to that question.
Attributes travel as strings, and the parser reads them back as a type. Anything it can't read (an option you no longer offer, a value your app set under the same key, the strings "null" and "undefined") is null, which reads as unanswered. Numbers and booleans your app set read as their string form. An attribute can't be removed from a paywall, so the setter takes no null; one passed at runtime is ignored with a console warning, and writing the value already stored sends nothing.
Name the keys for the people who will query them. Campaign audiences target user attributes and analytics segments by them, so use stable snake_case keys prefixed by the flow (onboarding_goal, not goal or a copy label), short enum values rather than translated copy, and never rename a key once it has shipped. The onboarding quiz example is the full pattern.
On a web funnel, keep answers in the URL with useQueryState too, since that is what survives the browser hand-off, and save them as attributes as well so campaigns and analytics see them.
Shared chrome
Put back buttons, step counters, and the close button in layout.tsx, and drive them from router state so they can never drift from the stack:
const router = useRouter();
{router.canGoBack()
? <button onClick={() => { haptics.light(); router.back(); }}>Back</button>
: <span className="chrome-button" />} /* placeholder keeps the layout stable */
<span>{router.depth + 1} of 3</span>depth + 1 works as a step counter only in linear flows. In a branching flow, a page's depth isn't its step number. Label steps per page instead.
The framework wraps the layout in a flex column that already fills the viewport inside the paywall's insets, so a layout's shell is a flex child, not a full-height box:
.shell { display: flex; flex: 1 1 auto; flex-direction: column; }The pages fill whatever the shell leaves them. A shell that sets min-height: 100dvh overflows the wrapper by the insets and scrolls; a layout that isn't a flex column can size the pages explicitly with --sw-routes-height on :root (default auto). --sw-background is needed on every paywall, layout or not; see Styling.
Position overlay chrome absolutely over the pages rather than as a bar above them. Each page paints its own background, so a bar of its own shows as a seam during transitions. The layout is already inside the paywall's insets and positions against the framework's content box, so position: absolute; top: 4px is below the status bar with no positioned ancestor of your own; give every page enough top padding to start below the chrome.
A funnel is one paywall, not several
Multi-step flows (onboarding quizzes, web funnels) are one paywall whose steps are pages, not a chain of separate paywalls. Every step is a router.push in the same flow, so there's no load between steps and nothing to re-fetch. The structure is identical (config.ts plus app/ pages plus layout.tsx), and funnels live in superwall/funnels/<id>/ with exactly the same shape.
The web funnel example is the reference: question steps kept in the URL, a typed plan selector, then purchase(reference) at the end, with web checkout taking payment in the same flow. Funnels usually want transition: "shift" in config.ts, see Transitions.
Where transitions and animation fit
How pages move (the built-in transitions, custom ones, and bottom sheets) is covered in Transitions. Animation inside a page (Motion, CSS) is yours; moving between pages stays the router's job. Keeping that line means spamming navigation can never fight your component animations. And entry animations gate on presentation, never mount. See Lifecycle & events.
Assets for upcoming pages preload automatically while the user is on the current page, see Assets.
How is this guide?