For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: 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
- Share feature keys via
packages/billing/shared/src/config/features.ts(same as web). - Read store entitlements with
useCustomer()from@workspace/billing-mobile. - Merge entitlements into
getActivePlan()alongsidebilling.queries.summary. - Gate UI locally; still enforce on the API for anything security-sensitive.
- Route upgrades through your paywall provider or the native subscription management sheet.
Web vs. mobile billing
| Concern | Web | Mobile |
|---|---|---|
| Checkout | Stripe, Lemon Squeezy, Polar, etc. | App Store / Play Store via RevenueCat or Superwall |
| Entitlements | Subscriptions + orders in Postgres | useCustomer().entitlements + API summary |
| Upgrade UX | Pricing page or billing portal | Paywall, native manage-subscriptions sheet |
| Source of truth for access | API + billing config | API (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 databaseOn 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:
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:
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():
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:
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:
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:
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:
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:
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
- Sandbox purchase — buy a test subscription in App Store Connect / Play Console sandbox.
- Restore purchases — verify
useCustomer()entitlements refresh afterrestorePurchases(). - Cross-platform — purchase on web, open mobile, confirm
summary+ entitlements resolve to Premium. - Expiration — cancel in sandbox, wait for expiry, confirm gates reappear.
- Organization billing — switch org in account switcher;
referenceIdshould change and plan should follow org purchases.
Checklist
- Feature keys live in
features.tsand billing config - RevenueCat/Superwall entitlement ids match billing variant ids
useBillingAccessmerges 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