Skip to Content
BuildFunnel Config

Funnel Config

funnel.ts is the spine. This guide walks through defineFunnel field by field — the pages array, routing, offering slots, locales, and the checkout provider. For the exhaustive type tables, see the funnel definition reference; this page is the working knowledge you need to write one.

defineFunnel types the object you pass as a FunnelDefinition, so you get autocomplete and type-checking on the spine. Export it exactly as export const config = defineFunnel(...) — the editor relies on that name to read your funnel back.

funnel.ts
import { defineFunnel } from '@appfunnel-dev/sdk' export const config = defineFunnel({ id: 'funnel', checkout: 'stripe', responses: { email: { type: 'string', default: '' }, marketingConsent: { type: 'boolean', default: false }, }, offerings: { plan: null }, pages: [ { key: 'welcome', type: 'default' }, { key: 'email', type: 'email-capture' }, { key: 'paywall', type: 'paywall' }, { key: 'finish', type: 'finish' }, ], })
FieldRequiredWhat it does
idyesFunnel identifier. The template uses 'funnel'.
pagesyes (in practice)Ordered page list — the source of truth for order, type, slug, and routing.
checkoutfor paywalls'stripe' or 'paddle'. Sets your payment provider.
responsesnoThe captured-input variables (responses.*) and their defaults.
datanoWorking/scratch variables (data.*) and their defaults.
offeringsfor paywallsOffering slots — see Offerings.
localesnoMulti-locale config. Omit for a single-locale funnel.
locationAwareCurrencynotrue asks the payment provider for a geo-specific price. false (default) uses one fixed currency. Appfunnel does no FX itself.

Pages

Each entry in pages describes one page. The only required field is key; everything else has a sensible default.

pages: [ { key: 'welcome' }, // type defaults to 'default', slug to 'welcome' { key: 'goal', type: 'default' }, { key: 'email', type: 'email-capture' }, { key: 'paywall', type: 'paywall', slug: 'offer' }, { key: 'finish', type: 'finish' }, ]
FieldWhat it does
keyPage identity. Must equal the pages/<key>.tsx filename.
typeDrives runtime behavior and analytics. Defaults to 'default'.
slugThe URL slug for this page. Defaults to the page key.
nextRouting edges out of this page (see Routing). Omit for linear flow.
guardAn entry precondition, applied only on deep-link/reload restoration (see Entry guards).
dynamicOpt a page out of instant-serve when its structure depends on the visitor (see Dynamic pages).

The five page types

type is more than a label — it drives runtime behavior and the analytics the platform records for the page.

typeMeaning
'default'A plain content or question step. The baseline.
'email-capture'The identity step: it captures user.email, validated.
'paywall'An offer/pricing page. Drives paywall-view and paywall conversion analytics.
'upsell'A post-purchase one-click offer. Drives upsell-rate analytics and off-session charges.
'finish'The terminal success page. Records completion and ends the flow.

Use 'email-capture' for your email step, not 'default'. It’s the semantically correct type: it gets email validation, records the identity event, and satisfies the implicit email guard that paywall and upsell pages carry (more below).

Routing

By default pages run top to bottom in file order. To branch, give a page a next array of routes. Each route is { to, when? }; the first route whose when gate passes (or has no when) wins. If no route matches, the visitor falls through to the linear next page; if there’s no next page, the funnel ends.

{ key: 'goal', next: [ { to: 'premium', when: { field: 'responses.goal', op: 'eq', value: 'gain' } }, { to: 'standard' }, // fallback — no `when`, always matches ], }

A gate is one of two flavors: a declarative Condition or a code Predicate.

Condition — the default

A Condition is a plain data object: { field, op, value }. It’s the default because it stays editable in the dashboard’s Flow view — the platform renders it as a field op value row you (or a teammate, or the AI assistant) can change without touching code.

{ to: 'premium', when: { field: 'responses.goal', op: 'eq', value: 'gain' } }
  • field — a dotted path into the funnel snapshot: responses.goal, user.country, context.device.isMobile.
  • op — the comparison operator.
  • value — a scalar, or an array (required for in).

The operators:

opTrue when
eq / neqField equals / does not equal value.
gt / gte / lt / lteNumeric comparison. Both sides must be numbers.
inField is one of value (an array).
containsString field contains the substring, or array field includes the member.
existsField is not undefined, null, or ''.
emptyField is undefined, null, '', or an empty array.

Predicate — the escape hatch

A Predicate is a function of the snapshot returning a boolean. Use it only when a Condition can’t express the logic:

{ to: 'premium', when: (s) => s.responses.goal === 'gain' && s.context.device.isMobile }

A predicate works at runtime, but it makes that page’s routing opaque in the Flow view — the dashboard can’t render or edit a function body, so the route becomes code-only. Reach for a predicate only when a declarative Condition genuinely can’t express the branch. Default to Condition so the flow stays dashboard-editable.

The snapshot a gate sees is { user, responses, data, context }. The first three are your writable namespaces (flat records); context is the read-only computed context (device, locale, utm, and so on). See State for what lives where.

Entry guards

A page’s guard is an entry precondition checked only when a visitor deep-links or reloads straight onto that page. It does not run during normal in-funnel navigation. If someone lands on a guarded page from a cold URL and the guard fails against the restored session, they’re bounced to the start page.

paywall and upsell pages carry a built-in guard requiring user.email to exist — a cold deep-link into a paywall without a captured email falls back to the start. You don’t write this; it comes from the page type. If you set an explicit guard, it fully replaces the built-in one (they aren’t combined).

Dynamic pages

By default Appfunnel serves pages so they paint instantly on a cold click. That works when a page’s structure is fixed and only its values vary with what the visitor has entered. Set dynamic: true on a page whose structure — which sections exist, not just the text inside them — depends on the visitor’s answers, so it’s rendered fresh per visitor instead.

{ key: 'plan', type: 'paywall', dynamic: true }

Value-only variation (a name, a price, a chosen answer echoed back) does not need dynamic. Only reach for it when the set of rendered sections itself changes per visitor. prerender: false is an equivalent spelling of dynamic: true.

Responses & data

responses and data declare your writable variables and their defaults. Both are records of VariableConfig:

responses: { email: { type: 'string', default: '' }, goal: { type: 'string', default: '' }, marketingConsent: { type: 'boolean', default: false }, }, data: { quizScore: { type: 'number', default: 0 }, },

type is one of 'string' | 'number' | 'boolean' | 'stringArray'. The difference between the two namespaces is intent and analytics:

  • responses is the primary captured-input namespace. Writing a response persists it and feeds it into analytics aggregation. This is where visitor answers go.
  • data is working/scratch state. It persists across reload and return visits, but it’s kept out of response aggregation. Use it for computed or intermediate values you don’t want reported as answers.

You read and write both with hooks — useResponse('goal'), useData('quizScore'). See State.

Offerings — slots

Offerings in funnel.ts are slots, not the offerings themselves. A slot is a stable local name your pages reference; the config maps that slot to the offering it currently sells.

offerings: { plan: null } // declared, UNASSIGNED offerings: { plan: 'pro_monthly' } // slot 'plan' sells offering 'pro_monthly'

Your pages reference the slot name — useOffering('plan'), <Checkout offering="plan" /> — and never the offering key. Swapping which offering a slot points to changes the offer without touching a single page.

Slots and offering keys are two different identities. useOffering('plan') returns undefined until the plan slot is assigned to an offering. offerings: { plan: null } is a declared-but-unassigned slot — the paywall renders its “add an offering” hint, not a price. Assign slots in the editor’s Offerings view (or ask the assistant). See Offerings & Catalog.

You assign slots in the editor, not in code — leaving the code stable while the offer changes. Prices come from your catalog per environment (Live vs Test); they’re never hardcoded in funnel.ts. Full detail on reading offerings in pages is in Offerings & Checkout.

Checkout provider

Pick the provider once, at the top of the config:

checkout: 'stripe' // or 'paddle'

Your choice of provider constrains which checkout surfaces are legal on your paywall — Paddle and Stripe support different presentation modes, and using an unsupported one is invalid. That matrix lives in Offerings & Checkout.

Locales

Omit locales entirely for a single-locale funnel. To offer more than one language, declare them here:

locales: { default: 'en', supported: ['en', 'de'], fallback: 'en', autoDetectLanguage: true, }
FieldWhat it does
defaultThe locale served when the visitor specifies none.
supportedEvery locale the funnel offers.
fallbackLocale used for a catalog key missing in the active one.
autoDetectLanguagetrue: a visitor with no explicit language (no /<locale>/ path prefix, no ?language=) gets the best Accept-Language match among supported (en-GB matches en), falling back to default. false or absent: an unspecified visitor always gets default, and the URL is the only way to switch.

This block declares the languages; the actual translation strings live in messages/<locale>.json, and you enable and manage them in the editor. See Localization.

Do it with an agent: “Add a goal question page after welcome that routes to a premium paywall when the answer is ‘gain’, and a standard paywall otherwise.” The assistant edits funnel.ts and scaffolds the pages, keeping the routing declarative so it stays editable in the Flow view.

Last updated on