Skip to Content
ReferenceFunnel Definition

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.

funnel.ts
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): FunnelDefinition

defineFunnel 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

FieldTypeRequiredBehavior
idstringyesFunnel identifier. The default template uses 'funnel'.
pagesFunnelPage[]recommendedOrdered page list — the source of truth for order, type, slug, and declarative routing. Each key maps to pages/<key>.tsx.
checkout'stripe' | 'paddle'for paywallsWhich checkout provider the paywall uses. The build wires the matching driver; you never import it. See <Checkout>.
responsesRecord<string, VariableConfig>noDeclares the responses.* runtime variables and their defaults. The primary captured-input namespace.
dataRecord<string, VariableConfig>noDeclares the data.* working/scratch variables and their defaults.
offeringsstring[] | Record<string, string | null>for paywallsOffering slots — the local keys your pages reference. See Offering slots.
localesFunnelLocalesnoMulti-locale config. Omit for a single-locale funnel. See Locales.
locationAwareCurrencybooleannotrue 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.

FieldTypeBehavior
keystringPage identity. Must equal the pages/<key>.tsx filename — keep them in lockstep.
typePageTypeDrives runtime behavior and analytics. Defaults to 'default'. See Page types.
slugstringURL slug for the page. Defaults to the page key.
nextRoute[]Ordered routing edges out of this page — first match wins. Omit for the default linear flow. See Routing.
guardGateEntry precondition for deep-link / reload restoration only (not in-funnel nav). See Guards.
dynamicbooleanBuild-time prerender opt-out. Set true when a page’s structure depends on session values. See Prerendering.
prerenderbooleanInverse alias of dynamicprerender: 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.

ValuePlatform 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 | Predicate

A 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 for in.

Condition operators

OpMeaningvalueExample
eqstrict equalityscalar{ field: 'responses.goal', op: 'eq', value: 'gain' }
neqstrict inequalityscalar{ field: 'responses.plan', op: 'neq', value: 'free' }
inmembership in a listarray{ field: 'user.country', op: 'in', value: ['US', 'CA'] }
gtgreater than (numbers only)number{ field: 'responses.age', op: 'gt', value: 17 }
gtegreater than or equal (numbers only)number{ field: 'responses.age', op: 'gte', value: 18 }
ltless than (numbers only)number{ field: 'data.score', op: 'lt', value: 50 }
lteless than or equal (numbers only)number{ field: 'data.score', op: 'lte', value: 50 }
existsvalue is present{ field: 'user.email', op: 'exists' }
emptyvalue is absent{ field: 'responses.goal', op: 'empty' }
containssubstring or array membershipscalar{ field: 'responses.tags', op: 'contains', value: 'fitness' }

Operator semantics, exactly:

  • exists is true when the value is not undefined, not null, and not ''.
  • empty is true for undefined, null, '', or an empty array.
  • contains works on strings (substring match, value coerced to string) and on arrays (membership).
  • gt / gte / lt / lte require both sides to be numbers — a non-numeric operand makes the comparison false, 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. useOffering returns undefined and <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, }
FieldTypeBehavior
defaultstringLocale served when the visitor specifies none.
supportedstring[]Every locale the funnel offers.
fallbackstring (optional)Fallback locale for missing catalog keys.
autoDetectLanguageboolean (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-GBen); 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 | undefined
responses: { 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: true

true 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/sdk version is pinned for you — leave it alone. See package.json.
  • mount.tsx — generated from funnel.ts for 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.

Last updated on