Funnel Definition
funnel.ts is the spine of a funnel — the single file that declares its pages, their order and types, the routing between them, the offering slots the paywall sells, and the locales the funnel serves. Everything else in the project (the page components, the layout, the styles) hangs off the shape you declare here.
The editor reads this file back to draw the flow graph and the routing UI, so it stays editable from both code and the visual builder — which is why it must be a plain object literal.
import { defineFunnel } from '@appfunnel-dev/sdk'
export const config = defineFunnel({
id: 'funnel',
checkout: 'stripe',
pages: [
{ key: 'welcome', type: 'default' },
{ key: 'email', type: 'email-capture' },
{ key: 'paywall', type: 'paywall' },
{ key: 'finish', type: 'finish' },
],
offerings: { plan: null },
})defineFunnel
function defineFunnel(def: FunnelDefinition): FunnelDefinitiondefineFunnel is a pure identity helper — it returns the object you pass, typed as FunnelDefinition. Its only job is TypeScript checking and editor autocomplete on the spine.
The exported symbol must be export const config = defineFunnel({ … }), and its argument must be a plain object literal: no computed keys, no spreads, no values pulled from other modules. A dynamic funnel.ts still runs, but the editor can no longer read your flow back — so the Flow view and routing UI go dark.
FunnelDefinition fields
| Field | Type | Required | Behavior |
|---|---|---|---|
id | string | yes | Funnel identifier. The default template uses 'funnel'. |
pages | FunnelPage[] | recommended | Ordered page list — the source of truth for order, type, slug, and declarative routing. Each key maps to pages/<key>.tsx. |
checkout | 'stripe' | 'paddle' | for paywalls | Which checkout provider the paywall uses. The build wires the matching driver; you never import it. See <Checkout>. |
responses | Record<string, VariableConfig> | no | Declares the responses.* runtime variables and their defaults. The primary captured-input namespace. |
data | Record<string, VariableConfig> | no | Declares the data.* working/scratch variables and their defaults. |
offerings | string[] | Record<string, string | null> | for paywalls | Offering slots — the local keys your pages reference. See Offering slots. |
locales | FunnelLocales | no | Multi-locale config. Omit for a single-locale funnel. See Locales. |
locationAwareCurrency | boolean | no | true shows the geo-specific price the payment provider resolves. false (default) uses a single fixed currency. See below. |
Guide: Funnel Config walks through authoring this file for a real funnel.
Pages
Each entry in pages is a flat FunnelPage object — PageMeta plus a required key. You write everything about a page’s platform behavior here; the page’s content lives in pages/<key>.tsx.
| Field | Type | Behavior |
|---|---|---|
key | string | Page identity. Must equal the pages/<key>.tsx filename — keep them in lockstep. |
type | PageType | Drives runtime behavior and analytics. Defaults to 'default'. See Page types. |
slug | string | URL slug for the page. Defaults to the page key. |
next | Route[] | Ordered routing edges out of this page — first match wins. Omit for the default linear flow. See Routing. |
guard | Gate | Entry precondition for deep-link / reload restoration only (not in-funnel nav). See Guards. |
dynamic | boolean | Build-time prerender opt-out. Set true when a page’s structure depends on session values. See Prerendering. |
prerender | boolean | Inverse alias of dynamic — prerender: false is identical to dynamic: true. Author preference only. |
Page types
type is a fixed string union. The value carries platform meaning — it drives which analytics a page feeds and, for paywall/upsell, an implicit entry guard.
| Value | Platform meaning |
|---|---|
'default' | Plain content or step page. The baseline. |
'email-capture' | Collects user.email — the mandatory identity step. Feeds validation and identity. |
'paywall' | The offer/pricing page. Drives paywall-view and paywall conversion-rate analytics. |
'upsell' | Post-purchase one-tap offer. Drives Upsell Rate and the off-session charge. |
'finish' | Terminal success / thank-you page. Feeds completion analytics and ends the flow. |
Use type: 'email-capture' for your email step, not type: 'default'.
email-capture is the semantically correct type: it gets email validation,
and the implicit paywall/upsell guard (below) depends on user.email having
been captured on a real capture step.
Type-implied entry guards. paywall and upsell pages carry a built-in guard requiring user.email to exist. A deep-link or reload straight into one of these pages without a captured email bounces to the start page. You don’t write this — but note that an explicit guard on the page fully overrides it (the two are not combined).
Guards
guard is an entry precondition that runs only when someone starts on this page — a deep link or a reload restoring a saved session. It does not run on normal in-funnel navigation (routing already gated that).
// An upsell page that can only be restored into if the purchase actually happened.
{ key: 'upsell', type: 'upsell', guard: { field: 'data.purchased', op: 'eq', value: true } }If the guard fails against the restored session, the visitor is sent to the start page instead. A guard is the same Gate shape as routing — a declarative Condition or a code Predicate. Omit it and the page is always restorable.
Dynamic vs prerender
By default every page is served ready-made, so it paints instantly on a cold ad click. Set dynamic: true (or the equivalent prerender: false) only when a page’s structure — which sections exist, not just their text — depends on the visitor’s answers. Such a page is rendered fresh for each visitor instead.
dynamic is for structural variation only. A page that swaps prices,
headlines, or copy into a fixed layout does not need dynamic: true —
value-only variation is already flash-free. Reach for dynamic only when
the set of rendered sections itself branches on session data. Marking pages
dynamic needlessly gives up the instant first paint.
Routing
Routing out of a page is a list of edges. A page’s next is an ordered Route[]; the first route whose when gate passes — or that has no when — wins. With no match, the flow falls through to the linear next page in file order; with nothing after it, the funnel ends.
interface Route {
to: string // a static target page key (the dashboard reads these to draw the graph)
when?: Gate // gate the edge; omit for an unconditional fallback edge
}
type Gate = Condition | PredicateA gate is one of two flavors.
Condition (declarative — dashboard-editable)
A Condition is a single field op value row the web builder can render and edit.
{ key: 'quiz', next: [
{ to: 'premium', when: { field: 'responses.goal', op: 'eq', value: 'gain' } },
{ to: 'standard' }, // unconditional fallback — first route above that fails falls here
] }interface Condition {
field: string // dotted path into the snapshot
op: ConditionOp
value?: VariableValue | VariableValue[]
}field— a dotted path into the snapshot:responses.goal,user.country,context.device.isMobile.op— one of nine operators (below).value— a scalar, or an array forin.
Condition operators
| Op | Meaning | value | Example |
|---|---|---|---|
eq | strict equality | scalar | { field: 'responses.goal', op: 'eq', value: 'gain' } |
neq | strict inequality | scalar | { field: 'responses.plan', op: 'neq', value: 'free' } |
in | membership in a list | array | { field: 'user.country', op: 'in', value: ['US', 'CA'] } |
gt | greater than (numbers only) | number | { field: 'responses.age', op: 'gt', value: 17 } |
gte | greater than or equal (numbers only) | number | { field: 'responses.age', op: 'gte', value: 18 } |
lt | less than (numbers only) | number | { field: 'data.score', op: 'lt', value: 50 } |
lte | less than or equal (numbers only) | number | { field: 'data.score', op: 'lte', value: 50 } |
exists | value is present | — | { field: 'user.email', op: 'exists' } |
empty | value is absent | — | { field: 'responses.goal', op: 'empty' } |
contains | substring or array membership | scalar | { field: 'responses.tags', op: 'contains', value: 'fitness' } |
Operator semantics, exactly:
existsis true when the value is notundefined, notnull, and not''.emptyis true forundefined,null,'', or an empty array.containsworks on strings (substring match, value coerced to string) and on arrays (membership).gt/gte/lt/lterequire both sides to be numbers — a non-numeric operand makes the comparisonfalse, never a coercion.
Predicate (code — escape hatch)
A Predicate is a function of the snapshot. Use it when a Condition row can’t express the logic (combining fields, computed comparisons).
type Predicate = (s: FunnelSnapshot) => boolean
{ key: 'quiz', next: [
{ to: 'premium', when: (s) => s.responses.goal === 'gain' && s.context.device.isMobile },
] }Keep routing declarative to keep it dashboard-editable. A Predicate
works at runtime, but that page’s routing becomes opaque in the web builder
— the UI can still see the to edge, but it can’t read or edit the
condition. Prefer Condition as the default; reach for Predicate only as
a code-only escape hatch when a single field op value row genuinely can’t
express the branch.
The snapshot
A gate — Condition or Predicate — evaluates against a FunnelSnapshot:
interface FunnelSnapshot {
user: Record<string, VariableValue> // writable
responses: Record<string, VariableValue> // writable
data: Record<string, VariableValue> // writable
context: FunnelContext // read-only, computed, nested
}The first three are the writable namespaces (flat maps). context is the read-only computed context — device, locale, utm, click-ids, and more. A Condition field or a Predicate reads these with the same dotted paths (context.device.isMobile, context.locale.region). See context hooks for the full FunnelContext shape.
Offering slots
Offerings in funnel.ts are slots, not the offerings themselves. A slot is a stable, local key your pages reference (useOffering('plan'), <Checkout offering="plan" />); funnel.ts maps that slot to the offering key it’s assigned to. Swapping the assignment changes the offer without touching a line of page code.
offerings: {
plan: null
} // declared but UNASSIGNED slot (the template default)
offerings: {
plan: 'pro_monthly'
} // slot 'plan' sells offering 'pro_monthly'
offerings: ['pro'] // legacy sugar for an identity slot ≡ { pro: 'pro' }A slot key and an offering key are two different identities.
useOffering('plan') takes the slot; the charge uses the offering key the
slot is assigned to (Offering.catalogKey). A null slot returns
undefined from useOffering and renders nothing — you must assign it
before the paywall shows a price. See the slot hazard
note on useOffering.
null— a declared-but-unassigned slot.useOfferingreturnsundefinedand<Checkout>shows a hint until you assign it.- A bare
string[]— legacy identity-slot sugar (['pro']≡{ pro: 'pro' }). Existing funnels keep working. - Assignment is done in the editor’s Offerings view (or by the AI assistant). It is not a price — prices resolve from the project catalog per environment (Live / Test) at render, never hardcoded here.
- Insertion order is preserved, so
useOfferings()is deterministic.
Guide: Offerings & Checkout.
Locales
Declares the locales the funnel offers. Omit for a single-locale funnel.
locales: {
default: 'en',
supported: ['en', 'de'],
fallback: 'en',
autoDetectLanguage: true,
}| Field | Type | Behavior |
|---|---|---|
default | string | Locale served when the visitor specifies none. |
supported | string[] | Every locale the funnel offers. |
fallback | string (optional) | Fallback locale for missing catalog keys. |
autoDetectLanguage | boolean (optional) | true → a visitor with no explicit language (no /<locale>/ path prefix, no ?language=) gets the best Accept-Language match among supported, base-matched (en-GB → en); no match falls to default. false or absent (default) → an unspecified visitor always gets default, and the URL is the only switch. |
Each locale is a flat key → string catalog at messages/<locale>.json. Guide: Localization. Reading translations at runtime is covered under i18n hooks.
Variable config
Each responses and data entry is a VariableConfig — it declares the variable’s type and default.
interface VariableConfig {
type: VariableType // 'string' | 'number' | 'boolean' | 'stringArray'
default?: VariableValue
persist?: boolean
}
// VariableValue = string | number | boolean | string[] | null | undefinedresponses: {
email: { type: 'string', default: '' },
marketingConsent: { type: 'boolean', default: false },
},
data: {
purchased: { type: 'boolean', default: false },
},responses.* feeds analytics aggregation; data.* is working state kept out of that aggregation. Both persist across reload and return. You read and write these at runtime with useResponse / useData.
Location-aware currency
locationAwareCurrency: truetrue shows the geo-specific currency and price the payment provider resolves (Stripe currency_options, Paddle price preview). false (default) uses a single fixed currency for everyone.
Appfunnel does no currency conversion of its own.
locationAwareCurrency only toggles whether the platform asks the provider
for the geo price — there is no FX layer on our side. When it’s off, every
visitor sees the one currency your catalog price is denominated in.
The rest of the project
funnel.ts is one of a fixed set of files in a funnel project:
package.json— yours to edit; add any npm packages your pages import. The@appfunnel-dev/sdkversion is pinned for you — leave it alone. Seepackage.json.mount.tsx— generated fromfunnel.tsfor you; wires your pages and checkout together. Never hand-edit it; see Generated files.
The files you author — pages/<key>.tsx, layout.tsx, styles.css, messages/<locale>.json — are covered in Project Structure. The hooks and components those files use are in Hooks and Components.