For the complete documentation index, see llms.txt. Prefer markdown by appending .md to documentation URLs or sending Accept: text/markdown.

Feature-based access

Gate TurboStarter mobile features by subscription plan. RevenueCat and Superwall entitlements, API checks, paywalls, and upgrade flows.

Mobile apps sell through the App Store and Google Play, which means entitlements often arrive from RevenueCat or Superwall before your own API summary catches up. TurboStarter merges both sources so getActivePlan() returns the same plan id your web app uses — and you can gate screens, API calls, and paywalls with one mental model.

This recipe walks through feature-based access on Expo / React Native: defining features, reading entitlements, enforcing limits, and showing native upgrade paths.

TL;DR

  1. Share feature keys via packages/billing/shared/src/config/features.ts (same as web).
  2. Read store entitlements with useCustomer() from @workspace/billing-mobile.
  3. Merge entitlements into getActivePlan() alongside billing.queries.summary.
  4. Gate UI locally; still enforce on the API for anything security-sensitive.
  5. Route upgrades through your paywall provider or the native subscription management sheet.

Web vs. mobile billing

ConcernWebMobile
CheckoutStripe, Lemon Squeezy, Polar, etc.App Store / Play Store via RevenueCat or Superwall
EntitlementsSubscriptions + orders in PostgresuseCustomer().entitlements + API summary
Upgrade UXPricing page or billing portalPaywall, native manage-subscriptions sheet
Source of truth for accessAPI + billing configAPI (client checks are UX only)

The billing config (@workspace/billing) is shared. Feature constants, plan tiers, and checkPlanLimit() work identically across platforms.

Read the web feature-based access recipe first for the shared foundation — features.ts, isFeatureAvailable(), and billing config. This page focuses on what changes on mobile.

The mobile entitlement flow

App stores (RevenueCat / Superwall)


 useCustomer() entitlements ──┐
                              ├──► getActivePlan() ──► isFeatureAvailable()
 billing.queries.summary ─────┘              │
                                              ├──► Paywall or feature UI

 billing.queries.summary ──────────────────────┴──► API enforceFeatureAvailable()

        └── webhooks sync purchases to database

On launch, BillingProvider identifies the user with the mobile billing SDK. Purchases update entitlements locally; webhooks sync the same data to your database for API enforcement.

Share feature definitions with web

Mobile does not get a separate feature list. Edit packages/billing/shared/src/config/features.ts and the billing config once — both apps import @workspace/billing.

If you have not added isFeatureAvailable() yet, follow the web recipe — add it to packages/billing/shared/src/utils/plan.ts and export it through @workspace/billing.

Mobile variants in the config use store product identifiers as variant id values. Those ids must match RevenueCat offerings or Superwall products so findPlanByVariantId() resolves the correct plan.

Read entitlements from the billing provider

useCustomer() wraps RevenueCat (or Superwall) and normalizes entitlements:

packages/billing/mobile/src/providers/revenuecat/hooks/use-customer.tsx
const entitlements = useMemo(() => {
  return Object.values(customer.data?.entitlements.all ?? {}).map(
    (entitlement) => ({
      id: entitlement.identifier.toLowerCase(),
      active: entitlement.isActive,
      variantId: entitlement.productIdentifier,
    }),
  );
}, [customer.data]);

Identify users after auth so purchases attach to the right account:

apps/mobile/src/lib/providers/billing.tsx
const { identify, reset } = useCustomer();

useEffect(() => {
  if (session.data?.user.id) {
    identify(session.data.user.id, { email: session.data.user.email });
  } else {
    reset();
  }
}, [session.data?.user.id]);

Configure RevenueCat or Superwall so entitlement identifiers align with plan ids or variant ids in your billing config.

Merge entitlements with the API summary

The account switcher shows the canonical merge pattern — always pass both data sources into getActivePlan():

apps/mobile/src/modules/organization/account-switcher.tsx
import { getActivePlan } from "@workspace/billing";
import { useCustomer } from "@workspace/billing-mobile";

const { entitlements } = useCustomer();
const summary = useQuery(
  billing.queries.summary.get(
    activeOrganization.data?.id ?? session.data?.user.id,
  ),
);

const activePlan = getActivePlan(
  summary.data?.map((customer) => ({
    ...customer,
    entitlements,
  })),
);

Why merge?

  • Store purchases may activate before webhooks finish syncing.
  • Web subscriptions (e.g. user bought on desktop) still appear in summary.
  • getActivePlan() picks the highest plan across all sources.

Extract a hook so every screen uses the same logic:

apps/mobile/src/modules/billing/hooks/use-active-plan.ts
import { useQuery } from "@tanstack/react-query";
import { getActivePlan, isFeatureAvailable } from "@workspace/billing";
import { useCustomer } from "@workspace/billing-mobile";

import { billing } from "~/modules/billing/lib/api";

import type { Feature } from "@workspace/billing";

export const useBillingAccess = (referenceId: string) => {
  const { entitlements } = useCustomer();
  const summary = useQuery({
    ...billing.queries.summary.get(referenceId),
    enabled: !!referenceId,
  });

  const mergedSummary = summary.data?.map((customer) => ({
    ...customer,
    entitlements,
  }));

  const activePlan = getActivePlan(mergedSummary);

  const hasFeature = (feature: Feature) =>
    isFeatureAvailable(mergedSummary ?? [], feature);

  return {
    activePlan,
    hasFeature,
    isLoading: summary.isLoading,
    summary: mergedSummary,
  };
};

Gate screens and actions

Full-screen gate

Show a paywall or upgrade screen when the feature is missing:

apps/mobile/src/app/dashboard/teams.tsx
import { FEATURES, BillingPlan } from "@workspace/billing";
import { Text } from "@workspace/ui-mobile/text";
import { Button } from "@workspace/ui-mobile/button";

import { useBillingAccess } from "~/modules/billing/hooks/use-active-plan";
import { openPaywall } from "~/modules/billing/paywall";

export default function TeamsScreen() {
  const { hasFeature, isLoading } = useBillingAccess(referenceId);

  if (isLoading) {
    return <TeamsSkeleton />;
  }

  if (!hasFeature(FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION)) {
    return (
      <View className="flex-1 items-center justify-center gap-4 p-6">
        <Text className="text-center text-lg font-semibold">
          Teams are available on Premium
        </Text>
        <Button onPress={() => openPaywall("teams")}>
          <Text>View plans</Text>
        </Button>
      </View>
    );
  }

  return <TeamsList />;
}

Inline gate

Disable buttons instead of hiding entire tabs when you want discovery:

<Button
  disabled={!hasFeature(FEATURES[BillingPlan.PREMIUM].ADVANCED_REPORTS)}
  onPress={
    hasFeature(FEATURES[BillingPlan.PREMIUM].ADVANCED_REPORTS)
      ? exportReport
      : () => openPaywall("advanced_reports")
  }
>
  <Text>Export report</Text>
</Button>

Feature list on billing screens

Reuse FeaturesList to show what each plan includes:

apps/mobile/src/modules/billing/features-list.tsx
import { findPlanById } from "@workspace/billing";

export const FeaturesList = ({ planId }: { planId: BillingPlan }) => {
  const plan = findPlanById(planId);
  // renders translated feature.* keys
};

Show paywalls and manage subscriptions

RevenueCat / Superwall paywall

Trigger the provider paywall when users tap a locked feature. Configure the paywall in the provider dashboard — no app store review for copy changes when using Superwall.

Map paywall placements to feature constants in one file so marketing and engineering share vocabulary:

apps/mobile/src/modules/billing/paywall.ts
export const PAYWALL_PLACEMENTS = {
  teams: "teams_upgrade",
  advanced_reports: "reports_upgrade",
} as const;

export const openPaywall = async (
  placement: keyof typeof PAYWALL_PLACEMENTS,
) => {
  // RevenueCat: Purchases.presentPaywall()
  // Superwall: Superwall.shared.register(placement)
};

Native subscription management

For existing subscribers, useCustomer().linkToPortal() opens the App Store or Play Store subscription management UI:

apps/mobile/src/modules/billing/portal/native-portal-link.tsx
const { linkToPortal } = useCustomer();

<Button
  onPress={() => linkToPortal({ store: MobileStore.APP_STORE, variantId })}
>
  <Text>Manage subscription</Text>
</Button>;

Enforce limits on mobile mutations

Limits use the same checkPlanLimit() helper as web. Call it before creating resources — ideally in the API mutation the mobile app already uses via tRPC/Hono:

const { allowed, remaining } = checkPlanLimit({
  id: activePlan,
  key: "projects",
  currentUsage: projectCount,
});

if (!allowed) {
  throw new HttpException(HttpStatusCode.PAYMENT_REQUIRED, {
    code: "error.limitReached",
  });
}

On the client, show remaining capacity in settings or billing screens so users upgrade before hitting a hard wall.

Keep API enforcement in sync

Mobile clients can be patched, jailbroken, or run offline caches. Always duplicate feature checks on the server using enforceFeatureAvailable() from the web recipe.

The mobile app should treat 402 / error.upgradeRequired responses as a signal to open the paywall:

onError: (error) => {
  if (error.code === "error.upgradeRequired") {
    openPaywall("default");
    return;
  }
  toast.error(error.message);
},

Test on real devices

  1. Sandbox purchase — buy a test subscription in App Store Connect / Play Console sandbox.
  2. Restore purchases — verify useCustomer() entitlements refresh after restorePurchases().
  3. Cross-platform — purchase on web, open mobile, confirm summary + entitlements resolve to Premium.
  4. Expiration — cancel in sandbox, wait for expiry, confirm gates reappear.
  5. Organization billing — switch org in account switcher; referenceId should change and plan should follow org purchases.

Checklist

  • Feature keys live in features.ts and billing config
  • RevenueCat/Superwall entitlement ids match billing variant ids
  • useBillingAccess merges entitlements + summary everywhere
  • Locked features show paywall, not a crash or blank screen
  • API routes use enforceFeatureAvailable()
  • Limits use checkPlanLimit() on the server
  • Restore purchases tested on iOS and Android

How is this guide?

Last updated on

On this page

Ship your startup everywhere. In minutes.Try TurboStarter