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.

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 a page in the stack

router.name;    // current page
router.depth;   // pages underneath (index = 0)

If you've used expo-router, this is its API, method for method. 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.
  • Fire haptics.light() before every push and back. iOS gives no feedback of its own on navigation inside a paywall.
  • 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.

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.

When the layout wraps chrome around the pages, set two variables in :root:

:root {
  --sw-background: var(--bg);   /* pages are opaque; give them your background */
  --sw-routes-height: auto;     /* let the layout own the height, or its footer is pushed off-screen */
}

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.

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, a typed plan selector, then purchase(reference) at the end — with web checkout taking payment in the same flow.

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?

On this page