For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Onboarding flow
Customize TurboStarter mobile onboarding - welcome, setup steps, and an optional hard paywall with RevenueCat or Superwall.
TurboStarter ships a first-run setup on mobile: welcome carousel → auth → multi-step wizard → optional paywall → dashboard. Progress is stored locally so users can resume after killing the app.
This recipe shows how that flow works, how to add your own steps, and how to turn the skippable paywall into a hard paywall when you need payment before the product.
TL;DR
- Landing logic lives in
apps/mobile/src/app/index.tsx- session + unfinished steps decide the next screen. - Wizard shell + step list live in
apps/mobile/src/app/(setup)/steps/_layout.tsx. - Soft paywall (default):
usePaywall({ trigger: "onboarding" })with a Skip button. - Hard paywall: remove Skip, only call
goNext()after a successful purchase, and re-check on every launch. - For cross-device completion, mirror a server flag (see the web recipe).
What you get out of the box?
TurboStarter mobile includes ready-made onboarding screens - welcome, auth, setup steps, and optional paywall - so users land in the right place on first run. You can customize the flow, reorder or add steps, and progress is saved locally for seamless resumes.
Below, see which screens come standard and how the routing works.
| Screen | Path | File |
|---|---|---|
| Welcome | /welcome | apps/mobile/src/app/(setup)/welcome.tsx |
| Auth | /auth/* | apps/mobile/src/app/(setup)/auth/ |
| Intro | /steps/start | apps/mobile/src/app/(setup)/steps/start.tsx |
| Consents | /steps/required | apps/mobile/src/app/(setup)/steps/required.tsx |
| Paywall | /steps/paywall | apps/mobile/src/app/(setup)/steps/paywall.tsx |
Paths are centralized in apps/mobile/src/config/paths.ts under pathsConfig.setup.
Launch routing
apps/mobile/src/app/index.tsx picks the next screen in order:
- No session - send the user to
/welcome, then through/auth/*. - Session + unfinished setup - resume the current step under
/steps/*(Zustand + AsyncStorage). - Session + setup done - open the user or organization dashboard.
Soft vs hard paywall
| Mode | Behavior | When to use |
|---|---|---|
| Soft (default) | Paywall step shows; Skip goes to dashboard on Free | Freemium, trials, feature upsells later |
| Hard | No Skip; purchase (or active entitlement) required before dashboard | Upfront pricing, consumer apps, high CAC |
Feature gating after onboarding is covered in Feature-based access. Hard paywall here means the whole app is blocked until there is a paid plan.
Landing router
apps/mobile/src/app/index.tsx is the single decision point after launch:
export default function Index() {
const { data, isPending } = authClient.useSession();
const { step } = useSetupSteps();
if (isPending) {
return <Spinner modal={false} />;
}
if (!data) {
return <Redirect href={pathsConfig.setup.welcome} />;
}
if (step) {
return <Redirect href={step} />;
}
if (data.session.activeOrganizationId) {
return <Redirect href={pathsConfig.dashboard.organization.index} />;
}
return <Redirect href={pathsConfig.dashboard.user.index} />;
}step comes from a Zustand store persisted in AsyncStorage (name: "setup-steps"). When current === -1, there is no active step and the user reaches the dashboard.
Customize the step list
The wizard shell owns the ordered list of routes:
const steps = [
pathsConfig.setup.steps.start,
pathsConfig.setup.steps.required,
pathsConfig.setup.steps.paywall,
] as const;To add a step (for example “Create workspace”):
- Add the path in
apps/mobile/src/config/paths.ts:
steps: {
start: `${STEPS_PREFIX}/start`,
required: `${STEPS_PREFIX}/required`,
workspace: `${STEPS_PREFIX}/workspace`,
paywall: `${STEPS_PREFIX}/paywall`,
},- Insert it into the
stepsarray in_layout.tsx(order = user journey). - Create
apps/mobile/src/app/(setup)/steps/workspace.tsxthat callsgoNext()when done. - Add copy under
setup.steps.step.workspace.*inpackages/i18n/src/translations/en/marketing.json.
Progress dots and back/close chrome update automatically from the steps array length.
Required vs optional steps
Put blocking steps (legal consents, profile fields you need for the product) before the paywall. Put nice-to-have steps after purchase or skip - users abandon less when they feel progress.
Collect data inside a step
The consent step is the template for forms: React Hook Form + Zod, CTA disabled until valid, then goNext():
const form = useForm({
resolver: standardSchemaResolver(
z.object({
data: z.boolean(),
privacy: z.boolean(),
}),
),
defaultValues: { data: false, privacy: false },
});
// ...checkboxes...
<Button
disabled={Object.values(values).some((value) => !value)}
onPress={() => goNext()}
>
<Text>{t("continue")}</Text>
</Button>;For data you need later (workspace name, role, goals):
- Device-only - keep it in the same Zustand store as setup progress, or a dedicated persisted store.
- Server - call your Hono API (or Better Auth
updateUser) beforegoNext(), same pattern as any protected mutation.
Prefer writing to the server when the answer affects billing, orgs, or other platforms.
Keep the soft paywall (or remove it)
Default paywall step presents RevenueCat / Superwall with trigger "onboarding" and allows Skip:
const { present, result } = usePaywall({
onPurchase: () => onEvent("dismissed"),
onSkip: () => onEvent("skipped"),
// ...
});
<Button
onPress={() => {
goNext();
router.replace(pathsConfig.dashboard.user.index);
}}
variant="ghost"
>
<Text>{t("skip")}</Text>
</Button>
<Button
onPress={() => {
void present({ trigger: "onboarding" });
}}
>
<Text>{t("setup.steps.step.paywall.cta")}</Text>
</Button>Configure the "onboarding" placement in the RevenueCat or Superwall dashboard so copy and products can change without an app release (especially with Superwall).
To drop paywall from onboarding entirely, remove pathsConfig.setup.steps.paywall from the steps array and delete or ignore the screen. Upsell later from billing settings or feature gates.
Optional: enforce a hard paywall
Three changes turn the soft step into a gate.
1. Remove Skip and only advance on purchase
const { present, result } = usePaywall({
onPurchase: () => {
goNext();
router.replace(pathsConfig.dashboard.user.index);
},
onRestore: () => {
goNext();
router.replace(pathsConfig.dashboard.user.index);
},
});
// No Skip button - only the CTA that calls present({ trigger: "onboarding" })2. Block the dashboard until a paid plan is active
Local setup completion (current === -1) is not enough if someone restores an old install or clears AsyncStorage. After session is ready, also require a paid plan before leaving setup:
import {
BillingPlan,
getActivePlan,
isSubscriptionActive,
} from "@workspace/billing";
import { useCustomer } from "@workspace/billing-mobile";
// inside Index, after session exists:
const { entitlements } = useCustomer();
const summary = useQuery(billing.queries.summary.get(data.user.id));
const activePlan = getActivePlan(
summary.data?.map((customer) => ({ ...customer, entitlements })),
);
const hasPaidAccess =
activePlan !== BillingPlan.FREE &&
/* optionally: */ isSubscriptionActive(/* active subscription */);
if (!hasPaidAccess) {
return <Redirect href={pathsConfig.setup.steps.paywall} />;
}Tune hasPaidAccess to your model (any paid plan, specific entitlement id, trial allowed, etc.). Merge store entitlements with the API summary the same way as in feature-based access.
3. Keep API enforcement
A hard client gate is UX. Anything sensitive still needs enforceFeatureAvailable() / plan checks on the API so a patched client cannot skip payment.
Reset and re-test the flow
During development you will run the wizard many times:
| Action | How |
|---|---|
| Restart steps | Tap X in the steps header (reset() → welcome) or clear the setup-steps key in AsyncStorage |
| Fresh install | Delete the app / clear Expo data |
| Paywall sandbox | Use App Store / Play sandbox accounts; restore purchases after reinstall |
| E2E | Flows under apps/mobile/e2e/ already cover welcome → auth → steps |
After changing the steps array order, bump or clear the persisted store so old indices do not point at the wrong screen.
Checklist
- Step paths registered in
pathsConfig.setup.stepsand listed in_layout.tsx - Each step advances with
goNext()(orsetCurrent(-1)on the last intentional exit) - Marketing i18n keys exist for every step
- Soft paywall: Skip works; hard paywall: Skip removed + launch re-check for paid plan
- RevenueCat / Superwall
"onboarding"placement configured - API still enforces paid capabilities (feature-based access)
Other platforms
Web and extension do not ship this wizard by default - they use empty slots and thinner first-run UX. Build them with the same mental model (landing guard → steps → optional hard paywall):
Web onboarding
Fill dashboard onboarding routes, persist completion on the server, optional choose-plan gate.
Extension onboarding
First-run popup, web auth, and plan-aware empty states.
Mobile paywalls
RevenueCat, Superwall, and how triggers map to placements.
Feature-based access
Gate individual features after the user is inside the app.
How is this guide?
Last updated on