Web Funnels
Quizzes and checkout funnels on the web: one paywall whose steps are pages, every answer kept in the URL so the flow survives any browser hand-off, and payment at the end.
A web funnel is a paywall with checkout set: a few question pages, a plan, then purchase(). It is served as a normal web page, and a web page has one problem a native paywall never has: the person may change browsers halfway through. A link opened from Instagram or TikTok runs in that app's in-app browser; tapping "Open in Safari" (or being sent there to pay with Apple Pay) hands over the URL and nothing else. localStorage, cookies, React state, all of it stays behind. Hosted checkout comes back to a URL too, and a reload starts from scratch.
So a web funnel keeps its state in the URL. The router does its half automatically; your half is one rule.
The rule: every answer is useQueryState
On a web funnel, never hold an answer in useState, layout context, or a module. Single choice, multi choice, text input, the selected plan, anything the person entered lives in useQueryState, so any URL resumes the flow on the same step with the same answers.
import { parseAsArrayOf, parseAsStringEnum, useQueryState } from "superwall/navigation";
const GOALS = ["focus", "habit", "catch-up"] as const;
const goalParser = parseAsStringEnum(GOALS);
// single choice — set, then move on
const [goal, setGoal] = useQueryState("goal", goalParser);
const choose = (value: (typeof GOALS)[number]) => {
haptics.selection();
setGoal(value);
router.push("interests");
};// multi choice — an array of enum ids, toggled in place
const [interests, setInterests] = useQueryState(
"interests",
parseAsArrayOf(parseAsStringEnum(["reading", "writing", "speaking"])).withDefault([]),
);
const toggle = (id: Interest) =>
setInterests((current) =>
current.includes(id) ? current.filter((one) => one !== id) : [...current, id],
);// text input — a plain string; replaces are coalesced, so typing is safe
const [name, setName] = useQueryState("name");
<input value={name ?? ""} onChange={(event) => setName(event.target.value || null)} />// the selected plan, typed against config
const [plan, setPlan] = useQueryState("plan", parseAsStringEnum(["monthly", "annual"]).withDefault("annual"));Every later page reads the same hook: the plan page shows goal, the summary page lists interests, and purchase(plan) uses the selection, all with no context and no prop drilling. Guard reads on pages someone might land on directly: goal ? COPY[goal] : COPY.default.
The API is nuqs's, so its parsers read the same: parseAsString, parseAsInteger, parseAsFloat, parseAsBoolean, parseAsStringEnum, parseAsArrayOf, createParser, each with .withDefault() (removes null from the type and clears the key when the value equals the default) and .withOptions({ history, clearOnDefault }). Junk in the URL parses to the default. Full signatures are in Hooks.
The same hook on a native host, where there is no URL bar, is plain state shared across pages. A funnel written this way runs unchanged natively; only where the state is kept differs.
What the router does on its own
With checkout set, queryState defaults to on and the route stack is mirrored into one reserved param:
https://yourapp.superwall.app/funnel?sw_nav=index,goal,interests&goal=habit&interests=reading,speakingrouter.pushadds a browser history entry; back, replace and dismiss rewrite in place. The browser's back button isrouter.back(), including Android's hardware back.- Any URL rebuilds the stack it names, with no animation and one
entrypage view. A route that no longer exists starts the flow over atindex. - Writes are coalesced so a text input can't trip Safari's history rate limit, and anything pending is flushed the moment the page is hidden, the instant before a hand-off or the jump to hosted checkout.
- Foreign params (
utm_*, attribution) are left untouched, and survive the whole flow.
definePaywall({ queryState: false }) turns it off for a checkout surface; queryState: true turns it on for a web surface without checkout. See Config.
Branch on the answers
Branching reads the same state, so a branch taken before a hand-off is the branch resumed after it:
const [goal] = useQueryState("goal", goalParser);
const next = () => router.push(goal === "catch-up" ? "backlog" : "interests");Because the stack is in the URL as the routes actually visited, back always retraces the branch taken.
Keep the URL small and clean
About 2 kB is safe across every app and share sheet, and a question flow of twenty short answers is well under 500 bytes if you follow three habits:
- Enumerate. Store ids (
"habit"), never labels ("Build a habit").parseAsStringEnumgives you validation for free. - Short keys, cleared defaults.
goal, notselectedGoalOption; leaveclearOnDefaulton so untouched answers cost nothing. - Nothing personal. URLs end up in referrer and analytics logs. An email or a name belongs in the checkout sheet's own fields, not in the query string.
Keys starting with sw_, plus platform and transport, are reserved, the hook throws on them.
Move like a funnel
Set the funnel transition once; shift fades each step in as it drifts into place and drops the previous step outright, so a long flow never reads as a growing stack:
export default definePaywall({
name: "Onboarding",
transition: "shift",
checkout: { mode: "sheet", prefetch: "annual" },
products: { monthly: "live:price_…:no-trial", annual: "live:price_…:7days-free" },
});Then the plan page prefetches the selected product and purchase(plan) opens the sheet, see Web Checkout.
Checklist
checkoutset inconfig.ts;transition: "shift"- every answer, selection and input is
useQueryState, nouseStatefor anything the person entered - enum ids, short keys, defaults cleared, nothing personal
- later pages guard their reads, so a direct link never crashes
- test it: answer two questions, copy the studio's iframe URL into a new tab, and you should land on the same step with the same answers
The web-funnel example is the reference.
How is this guide?
Web Checkout
Sell the same paywall on the web with one config key, Stripe payment in a sheet, Apple Pay, or a hosted checkout page, with purchase() unchanged.
Variables & Personalization
React to user attributes, device state, and placement parameters, and write paywalls the dashboard can experiment on without a rebuild.