For the complete documentation index, see llms.txt. Prefer markdown by appending .md to documentation URLs or sending Accept: 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

  1. Landing logic lives in apps/mobile/src/app/index.tsx - session + unfinished steps decide the next screen.
  2. Wizard shell + step list live in apps/mobile/src/app/(setup)/steps/_layout.tsx.
  3. Soft paywall (default): usePaywall({ trigger: "onboarding" }) with a Skip button.
  4. Hard paywall: remove Skip, only call goNext() after a successful purchase, and re-check on every launch.
  5. 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.

ScreenPathFile
Welcome/welcomeapps/mobile/src/app/(setup)/welcome.tsx
Auth/auth/*apps/mobile/src/app/(setup)/auth/
Intro/steps/startapps/mobile/src/app/(setup)/steps/start.tsx
Consents/steps/requiredapps/mobile/src/app/(setup)/steps/required.tsx
Paywall/steps/paywallapps/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:

  1. No session - send the user to /welcome, then through /auth/*.
  2. Session + unfinished setup - resume the current step under /steps/* (Zustand + AsyncStorage).
  3. Session + setup done - open the user or organization dashboard.

Soft vs hard paywall

ModeBehaviorWhen to use
Soft (default)Paywall step shows; Skip goes to dashboard on FreeFreemium, trials, feature upsells later
HardNo Skip; purchase (or active entitlement) required before dashboardUpfront 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:

apps/mobile/src/app/index.tsx
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:

apps/mobile/src/app/(setup)/steps/_layout.tsx
const steps = [
  pathsConfig.setup.steps.start,
  pathsConfig.setup.steps.required,
  pathsConfig.setup.steps.paywall,
] as const;

To add a step (for example “Create workspace”):

  1. Add the path in apps/mobile/src/config/paths.ts:
apps/mobile/src/config/paths.ts
steps: {
  start: `${STEPS_PREFIX}/start`,
  required: `${STEPS_PREFIX}/required`,
  workspace: `${STEPS_PREFIX}/workspace`,
  paywall: `${STEPS_PREFIX}/paywall`,
},
  1. Insert it into the steps array in _layout.tsx (order = user journey).
  2. Create apps/mobile/src/app/(setup)/steps/workspace.tsx that calls goNext() when done.
  3. Add copy under setup.steps.step.workspace.* in packages/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():

apps/mobile/src/app/(setup)/steps/required.tsx
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) before goNext(), 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:

apps/mobile/src/app/(setup)/steps/paywall.tsx
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

apps/mobile/src/app/(setup)/steps/paywall.tsx
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:

apps/mobile/src/app/index.tsx
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:

ActionHow
Restart stepsTap X in the steps header (reset() → welcome) or clear the setup-steps key in AsyncStorage
Fresh installDelete the app / clear Expo data
Paywall sandboxUse App Store / Play sandbox accounts; restore purchases after reinstall
E2EFlows 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.steps and listed in _layout.tsx
  • Each step advances with goNext() (or setCurrent(-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):

How is this guide?

Last updated on

On this page

Ship your startup everywhere. In minutes.Try TurboStarter