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" — every flow starts here
├── plans.tsx "plans"
├── layout.tsx wraps every page (the one reserved name)
└── 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.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.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 and depth. 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.
Pass state between pages
Navigation carries no params, on purpose. Cross-page state has two homes:
layout.tsx stays mounted for the whole flow, React state or context there is visible to every page:
export default function Layout({ children }: PropsWithChildren) {
return <div className="shell"><Chrome />{children}</div>;
}A plain module works even after the collecting page is gone, the quiz pattern, from the onboarding quiz example (see Examples):
// components/answers.ts
export const answers: { goal?: Goal; level?: Level } = {};const choose = (value: Goal) => {
haptics.selection();
answers.goal = value;
router.push("level");
};Guard every read on the destination, answers.goal ? PLAN[answers.goal] : undefined, so a revisited page never crashes on a missing answer.
The URL, for flows on the web, and on a web funnel this is not one option among three. It is the rule. Neither home above 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.
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?