Server-driven onboarding/paywall flows for React Native apps. FlowKit fetches a small JSON config (flows, screens, SDUI templates) from a remote endpoint, caches it on-device, and renders the active flow screen — falling back to sane built-in defaults whenever the network, cache, or a screen reference is unavailable. When no flow is active, it renders your app as-is.
"dependencies": {
"flowkit": "github:Pera-Labs/flowkit#v0.6.0"
}import { FlowKitProvider } from 'flowkit';
export default function App() {
return (
<FlowKitProvider
appId="my-app" version="1.0.0"
screens={{ HomeScreen, GearScreen }}
actions={{ 'purchase.buy': (arg) => buy(arg) }}
>
<FallbackScreen />
</FlowKitProvider>
);
}Before v0.5.0, FlowKitProvider only rendered onboarding/paywall-style
flows; once entry.flowId resolved to main it rendered children — the
host app's own router — verbatim. Native and SDUI screens in main had to
be spliced into that router by hand.
As of v0.5.0, the Provider renders every flow, including main:
- Any screen (in any flow) is rendered from
config.screens[screenId]:kind: 'sdui'→<SduiScreen>(explicittemplate, or a bundledDEFAULT_SCREENStemplate);kind: 'native'→ the matching component from thescreensregistry prop, called as<Component flowkit={api} state={state} />. An unresolvable screen (missing ref, no template) is skipped and rendersnull— it never throws. - If
flows.main.type === 'tabs', the Provider renders a bottomTabShell: tabs aremain.screensfiltered through the same visibility rules as any other flow (hidden screens, unregistered native refs dropped), each tab'sidlooked up inconfig.screens[id]for an optionaltabLabel/labelandtabIcon/icon. Tapping a tab, or dispatchingnav.tab:<id>, switches the active tab (Provider-held state) without re-entering the flow sequencer. - If
flows.main.type !== 'tabs',main's first visible screen renders the same way any other flow's screen would. childrenis now a fallback only — rendered solely whenmainresolves to zero visible screens, in both the tabs and non-tabs branches (a tabs shell with 0 visible tabs — all hidden, all native refs missing from the registry, or config typo — also falls back tochildrenrather than rendering nothing, and logs aconsole.warnso the fallback is diagnosable). A host that already passeschildrenand has any usablemain/tabs config now gets the full-flow render instead ofchildren; that's the intended v0.5.0 fix, not a regression. A host with an empty/absentmainconfig keeps working exactly as before (renderschildren), so upgrading never crashes an app.- Native registry components already received
props.flowkit; they now also receiveprops.state(the samestateprop passed toFlowKitProvider, used for SDUI@S.*bindings) so a shared app-state object can drive both SDUI and native screens without a separate plumbing path.
| Prop | Type | Required | Description |
|---|---|---|---|
appId |
string | yes | Identifies this app's config on the remote endpoint and in local storage. |
version |
string | yes | App version, sent with config fetches (server can gate by version). |
endpoint |
string | no | Config API base URL. Defaults to the public FlowKit endpoint (https://appscreenshots.studio/api). |
theme |
object | no | Partial theme overrides, merged over DEFAULT_THEME. |
screens |
object | no | Map of id -> React component for native (non-SDUI) screens, and a registry of ids the sequencer treats as "available". (v0.5.0) These now render for every flow, including main; each gets props.flowkit + props.state. |
actions |
object | no | Map of action-name -> handler for nav.goto, purchase.*, and custom:* actions. |
defaultConfig |
object | no | Overrides the built-in DEFAULT_CONFIG(appId) used when no cached/remote config is available. |
children |
node | no | (v0.5.0) Fallback-only: rendered when main (or its tabs) has zero visible/usable screens. With a populated main flow, children is not rendered — see "full-flow app shell" above. |
state |
object | no | (v0.2.0) Live app state, exposed to templates as @S.*. |
catalogs |
object | no | (v0.2.0) Static list data (e.g. gear catalogs), exposed as @catalog.*. |
dataSources |
object | no | (v0.2.0) Map of key -> { resolver: async () => value, refetchOn? }. Resolved once on boot; exposed as @rc.* (see below). A rejected resolver sets an error state, it never crashes the app. |
appInfo |
object | no | (v0.4.0) Overrides/supplies any @app.* field (see below). Use this when your app already resolves expo-application/expo-constants/expo-updates itself (e.g. a bare workflow), or to supply reviewVersion, reviewUrl, supportEmail. |
useFlowKit() returns { dispatch(actionStr, payload?), config, entry } for
any descendant that needs to trigger a flow action manually.
SDUI screen nodes carry an action string, dispatched via dispatch(action, payload?):
flow.next— advance to the next screen in the current flow.flow.skip— mark the current flow complete and jump to itsendAction.flow.goto:<flowId>— jump directly to another flow.nav.goto:<screenId>(v0.6.0) — jumps straight to that screen id, in any flow (searches every flow'sscreensarray, includingmain). Resolves even when the current entry is alreadymain— unlikeflow.*/nav.back,nav.gotois never blocked by the "already at main" no-op guard. A screen id that's defined in a flow resolves even ifhidden: true(an explicit jump overrides the visibility filter that only exists to skip screens during normal forward/back sequencing); an id that isn't defined anywhere, or is defined but unrenderable (missing native ref in the registry, sdui with no template/bundled default), warns and no-ops — the app stays on its current screen. Callsactions['nav.goto'](arg, payload)first if the host registered one (e.g. for analytics); that handler doesn't suppress the built-in jump.nav.back(v0.3.0) — callsactions['nav.back'](arg, payload, api)if the host registered one; otherwise steps back one screen in the current flow (or returns tomainif already at the first screen). No screen needs its own bespoke goBack handler just to wire a back button.nav.tab:<id>(v0.5.0) — switches the active tab in aflows.main.type: 'tabs'shell. Callsactions['nav.tab'](arg, payload, api)if the host registered one, otherwise the Provider switches its own active-tab state.purchase.buy,purchase.restore(and any otherpurchase.*) — callsactions['purchase.buy'](arg, payload, api), etc. Unhandled purchase actions warn and no-op.custom:<name>orcustom:<name>:<rest>— callsactions[<name>](rest, payload, api). Unhandled custom actions warn and no-op.app.*(v0.4.0) — see below.
Handlers receive (arg, payload, api) — the third argument is the FlowKit
API object (same shape as useFlowKit()), so a handler can itself call
api.dispatch('flow.next') after finishing async work (e.g. completing a
purchase before advancing the flow).
A host handler in actions (e.g. actions['app.checkUpdate']) always wins;
otherwise FlowKit runs a best-effort built-in via dynamic require of the
relevant Expo module — missing module → warns and no-ops, never crashes:
app.checkUpdate—expo-updatescheckForUpdateAsync → fetchUpdateAsync → reloadAsync ("Yeni Sürümü Çek").app.resetOnboarding— clears the FlowKitfk.<appId>.statecache and recomputes the boot entry (re-triggers onboarding).app.openReview— opensappInfo.reviewUrlviaLinking.app.contactSupport— opensmailto:<appInfo.supportEmail>viaLinking.app.openLink:<url>— opens<url>viaLinking.
All three pass the URL through a scheme allowlist (https:, http:, mailto:,
tel:, case-insensitive; no control chars/whitespace) before calling
Linking.openURL — a rejected scheme warns and no-ops instead of opening.
This matters because these URLs are server-driven config (studio-editable),
not developer-authored literals.
FlowKitProvider assembles a built-in app data source at boot, resolved
the same way as @S/@catalog/@rc (no new binding mechanism — @app is
just data.app):
| Binding | Source |
|---|---|
@app.version |
expo-application nativeApplicationVersion, falling back to expo-constants expoConfig.version |
@app.buildNumber |
expo-application nativeBuildVersion |
@app.isReview |
env EXPO_PUBLIC_APP_STORE_REVIEW==='1', OR appInfo.reviewVersion === @app.version |
@app.isDev |
__DEV__ |
@app.updateId / @app.channel / @app.runtimeVersion |
expo-updates |
@app.configRevision |
the active config's revision |
Every field is best-effort — a module that isn't installed, or a field it
doesn't expose, resolves to null (never throws, never blanks the whole
binding). Pass the appInfo prop to override or supply any field yourself
(e.g. if your app already resolves these natively, or to add
reviewUrl/supportEmail/reviewVersion for the app.* actions above).
An optional flags object on the config ({ schemaVersion, appId, flags: { hardPaywall: false }, flows, screens })
is exposed as @flag.<name> in templates and when expressions — a
server-driven toggle with no app code change required. Omitted flags ->
@flag.* resolves to undefined (falsy), same as today's behavior.
stack, row, card, divider, iconTile, text, image, button, choiceGrid, progressDots, spacer, badge,
starRating, coverArt, priceOptionList, freqChart, knobGauge, signalChain.
An unrecognized node type is skipped (renders nothing) and logs a console.warn.
Container primitives (stack, row, card) accept props: { gap, align: 'start'|'center'|'end', justify, pad, wrap } and an explicit style object that merges last. stack is column, row is horizontal. divider is a thin rule; iconTile is a round emoji/image chip ({ icon, size, iconColor|glyphColor } — glyph color is styleable as of v0.3.0, was hardcoded before).
Any container node (stack, row, card) may also carry an action string (v0.3.0): the whole block becomes pressable (tap-to-advance), calling onAction(action, payload) — no separate button needed for a "tap anywhere to continue" screen.
starRating— a row of tappable stars (props: { max, action, color, emptyColor, value? }). Tapping staricallsonAction(action, { value: i }).coverArt— an image (src/uri) with a deterministic hashed-color + initials fallback when no URL is given (fallbackText/fallbackSeed,size,radius).priceOptionList— selectable rows rendered from asourcearray (typically@rc.offerings, already resolved to[{id, title, price, perMonth, badge?}, ...]by the binding layer). Tapping a row callsonAction(action, item);selectedIdhighlights the matching row.freqChart— static bars, no animation (see v0.3.0 below for dual series).knobGauge— static approximation. A labeled circle showingvalue+label. Not an animated radial dial — still deferred.
freqChartdual series — passa/b(numeric arrays) instead of/alongsidevalues/barsto overlay two curves on one shared scale (aColor/bColorto style each). This is the "theirs vs yours" comparison ToneAdapt'sdiff.jsonneeds. Still static/non-animated — both curves visible at once is the point, not motion.values/bars(single series) keep working unchanged;a/bwin if both are present. Layout math lives infreqChartBars()(src/styles.js), unit-tested independent of React Native.signalChain— a horizontal row of icon-tile-like nodes joined by connectors, e.g. guitar → amp → pedal → speaker (orig_chainscreens).{ nodes: [{ icon, label?, iconColor? }], connector?, tileSize? };connectordefaults to→and is never rendered after the last node. Layout insignalChainItems()(src/styles.js).buttonsize variants —variant: 'pill' | 'circle' | 'compact'(also accepted assize). Default (pill/omitted) is the existing 52px-minHeight pill;circleis a fixed 44×44 round chip (for a small back button);compactshrinks the minHeight to 36. Dimensions come frombuttonDims()(src/styles.js), unit-tested.nav.backaction — see Actions above.
Deferred to a later release (need heavier native work or additional generic primitives not yet justified by enough screens): searchInput/selectableList/stickyDock/noneRow (gear screens), checkBadge, backBar, sectionLabel, tabContainer.
settingsRow—{ icon, label, value?, action?, when? }. Left icon (emoji/glyph), label, optional right-hand value text and/or a chevron (shown wheneveractionis set); the whole row is tappable whenactionis present.versionRow— zero-config: renders@app.version/@app.buildNumberas "Version X (build Y)".buildNumbermissing/null → just "Version X".versionmissing/null → renders nothing (formatVersionLabel()insrc/styles.js, unit-tested).linkRow—{ label, url }→ tapping dispatchesapp.openLink:<url>.toggleRow—{ label, flagPath?, value?, action }→ a switch; initial state read from@flag.<flagPath>(orvalueif noflagPath); flipping it callsonAction(action, { value })so the host can persist/override the flag.
All four honor when and the existing theme tokens.
Template string leaves can reference four kinds of live data (the template JSON itself stays static/serializable):
$token— theme tokens, e.g.$accent,$bg(existing, resolved viatheme).@S.<path>— live app state, from thestateprop.@S.guitar→state.guitar. Dotted paths index into nested objects/arrays (@S.list.0.name).@catalog.<path>— static list/catalog data, from thecatalogsprop.@catalog.GUITARS.@rc.<path>— resolved value of thercentry indataSources(conventionally RevenueCat offerings).@rc.offerings,@rc.selectedPackageId.
An unknown prefix, or a path that does not resolve, is left as-is (the raw @... string) — bindings never throw and never blank out a template.
<FlowKitProvider
appId="my-app"
version="1.0.0"
dataSources={{
rc: {
resolver: async () => {
const offerings = await Purchases.getOfferings();
return {
offerings: mapPackagesToPriceOptionListItems(offerings.current),
selectedPackageId: offerings.current.availablePackages[0]?.identifier,
};
},
},
}}
>
<MainApp />
</FlowKitProvider>The resolver runs once on boot. While it is pending, any node with a
matching source: '@rc.<path>' prop (e.g. priceOptionList) renders its
loadingState sub-template if provided, else nothing. If the resolver
rejects, it renders errorState if provided, else nothing. Once resolved,
the node renders normally with @rc.* bindings filled in.
Any SDUI node may carry a when string to gate whether it renders at all:
{ "type": "button", "when": "!@isPro", "label": "Upgrade to Pro", "action": "nav.goto:paywall" }Supported forms (a tiny hand-rolled parser — no eval/new Function):
- Bare truthy binding:
"@rc.offerings","@S.tier" - Negation:
"!@isPro" - Equality:
"@isPro === false","@S.tier === 'gold'","@S.tier !== 'gold'" - Numeric comparison (v0.4.0):
>=,>,<,<=, e.g."@app.buildNumber >= 42". A missing/non-numeric resolved value or literal fails open (true).
Unprefixed shorthand flags (@isPro, @flay.enabled) resolve against @S.*
(i.e. @isPro ≡ @S.isPro) since that's where such flags conventionally
live; @S.*/@catalog.*/@rc.*/@app.*/@flag.* prefixes still address their own source.
Fail-open semantics: any unrecognized form, or any error while
evaluating, resolves to true — FlowKit would rather show an extra node
than silently hide one due to a template typo.
An optional minAppVersion string on the config, combined with a gate flow
(same shape as onboarding/paywall), routes the app to that flow at boot
when @app.version < minAppVersion (dot-segment numeric comparison, see
versionLt() in src/appinfo.js). Fail-open: a missing/unreadable
@app.version, a missing/disabled gate flow, or no visible screens in it
all skip the gate and boot normally — a malformed config never bricks the app.
Omitting minAppVersion leaves today's behavior unchanged.
FlowKit never blocks or crashes the host app:
- No network / stale cache → falls back to the last cached config, then to
the built-in
DEFAULT_CONFIG/DEFAULT_SCREENStemplates. - Unknown screen id or missing native component → skipped, sequencer moves
to
main. - Unknown SDUI node type → skipped with a warning, siblings still render.
- Unhandled action → warns and no-ops instead of throwing.
- Remote config refresh runs in the background and only updates the cache for the next app launch — it never mutates the flow already on screen.