Skip to Content
BuildPages & Navigation

Pages & Navigation

A page is a plain React component that reaches the runtime through hooks. This guide covers the page contract, the layout, moving between pages with useNavigation, the Choice/Next/Back components, reading progress with usePage, entry guards, and page variants. For the full hook and component signatures, see the hooks reference and components reference.

The page contract

Every page file lives at pages/<key>.tsx, default-exports one component, and wraps it in definePage:

pages/goal.tsx
import { definePage, Choice, Next } from '@appfunnel-dev/sdk' export default definePage(function Goal() { return ( <section> <h1 className="text-3xl font-bold tracking-tight">What's your goal?</h1> <Choice bind="responses.goal" options={[ { value: 'gain', label: 'Build muscle' }, { value: 'lose', label: 'Lose weight' }, ]} className="mt-6 block w-full rounded-lg border px-4 py-3 text-left" selectedClassName="border-indigo-600" /> <Next className="mt-6 rounded-lg bg-indigo-600 px-5 py-3 text-white" /> </section> ) })

definePage is an identity helper — it wraps your component and returns it typed. There’s no sdk prop and no props injected by the runtime. The component is a normal function component; everything it needs comes from hooks. The page’s key in funnel.ts must match the filename (pages/goal.tsx{ key: 'goal' }).

The layout

layout.tsx is persistent chrome around every page. Its contract:

layout.tsx
import { type ReactNode } from 'react' import { usePage } from '@appfunnel-dev/sdk' export default function Layout({ children }: { children: ReactNode }) { const page = usePage() return ( <div className="min-h-dvh"> <div style={{ width: `${page.progressPercentage}%` }} /> {children} </div> ) }
  • It default-exports a component receiving { children }: { children: ReactNode }.
  • It mounts once, inside the navigation context but outside the page swap. It does not remount when the visitor navigates — so state and animations in the layout persist.
  • Because it’s inside the nav context, it can call usePage() and useNavigation(). A progress bar driven by usePage().progressPercentage is the canonical use.

useNavigation() returns the navigation controls and the current page context. Call it from any page or from the layout.

import { useNavigation } from '@appfunnel-dev/sdk' function Continue() { const { next, back, canGoBack, isNavigating } = useNavigation() return ( <> {canGoBack && <button onClick={back}>Back</button>} <button onClick={next} disabled={isNavigating}> {isNavigating ? 'Loading…' : 'Continue'} </button> </> ) }
MemberWhat it does
next()Advances via the current page’s routing, or the linear next page if nothing matches. The current page stays on screen until the next one is ready — no flash.
back()Steps back through the funnel’s own history — not the browser’s back button.
go(key)Jumps directly to a page by key.
prefetch(key)Warms a code-split page chunk ahead of time — e.g. on hover — so navigation to it is instant. Idempotent; a no-op for eagerly-loaded pages.
pageThe live PageContext (see Progress).
nextCandidatesThe page keys reachable next. Appfunnel preloads these; you rarely read it directly.
canGoBacktrue when there’s history to step back into. Use it to hide a Back button on the first page.
isNavigatingtrue while a cold, code-split page chunk is resolving. Show a “working” affordance so a slow chunk doesn’t look like a dead click.

next() is what you wire to your primary CTA. It reads the routing you declared in funnel.ts — you never re-implement branch logic in the page.

Flow components — Choice, Next, Back

These are thin, restyleable wrappers over the navigation and field hooks. They pass className and DOM props straight through and impose no styling of their own. When you outgrow them, drop to the hooks — nothing is lost.

Choice

A single-select bound to a writable field. Renders one <button> per option with aria-pressed and data-selected for styling.

<Choice bind="responses.goal" options={['gain', 'lose']} // bare strings, or { value, label } className="block w-full rounded-lg border px-4 py-3" selectedClassName="border-indigo-600 bg-indigo-50" advanceOnSelect // call next() on select — for one-tap question pages />
PropWhat it does
bindThe writable path to store the choice, e.g. "responses.goal".
optionsAn array of string or { value, label }. A bare string is shorthand for { value, label: value }.
classNameApplied to every option button.
selectedClassNameAdded to the selected button.
advanceOnSelectCalls next() after a selection — for question pages where picking an answer should move forward immediately.

Next and Back

Buttons that call next() and back(). Both extend ButtonHTMLAttributes, so every button prop forwards.

<Next className="rounded-lg bg-indigo-600 px-5 py-3 text-white">Continue</Next> <Back className="text-slate-500">Back</Back>
  • <Next> calls next() unless your own onClick calls preventDefault(). Default label: “Continue”.
  • <Back> calls back() and renders nothing when there’s no history — so you don’t need to guard the first page yourself. Default label: “Back”.

Progress — usePage

usePage() returns the reactive page context — it’s sugar for useNavigation().page.

const page = usePage() // { key, slug, index, total, progressPercentage, startedAt }

The important detail is total: it’s the expected path length for this visitor’s current answers, traced through your routing — not the raw count of page files. So progressPercentage reaches 100% on whichever branch the funnel actually takes for this visitor, and a progress bar built on it is honest even with branching flows. It’s clamped to 100.

Entry guards

A page’s guard (declared in funnel.ts) is an entry precondition, and it’s narrower than it looks: it runs only on deep-link or reload restoration, not during normal in-funnel navigation. When a visitor’s session is restored from a cold URL onto a page whose guard fails against that restored snapshot, they’re sent to the start page instead. Within a live session, moving forward and back never triggers guards.

The honest way to think about it: guards protect against someone landing mid-funnel out of context (a shared or bookmarked deep link), not against your own next() calls. paywall and upsell pages get a built-in email guard for exactly this reason — a cold link into a paywall with no captured email bounces to the start. An explicit guard you write fully replaces that built-in one. See Funnel Config for the declaration.

Page variants

To A/B test a single page, add a variant file with an @ in its key: pages/welcome@b.tsx is a variant of the welcome slot. That @ is the only source-side marker you write — Appfunnel reads it to know the file is a variant sibling, the flow keeps the stable slot key (welcome), and only the rendered component swaps per visitor. The experiment wiring — weights, status, which variant wins — lives in the dashboard, not in your funnel source, and changes without recompiling. See Experiments for the full lifecycle.

Do it with an agent: “Turn the welcome headline into an A/B test with a punchier variant.” The assistant creates the pages/welcome@b.tsx sibling; you configure weights and start the test in the Flow view.

Last updated on