Subscriptions
Getting paid
Overview
Subscriptions use RevenueCat. There are two products: monthly and yearly, both gated behind a single entitlement called "GetFolio Pro". The app checks this entitlement to decide what features to show. Free users get 1 portfolio, 3 alerts, and 3 assistant messages per day. Premium unlocks unlimited everything. The useSubscriptionStore manages all of this on the client side. In development mode, isPremium always returns true so we can test premium features without buying anything.
How it works
On app launch, the store calls Purchases.configure() with the RevenueCat API key. This initializes the SDK and sets up a customer info listener that reacts to any subscription changes in real time.
After Clerk auth completes, we call Purchases.logIn(userId) to link the RevenueCat customer to the Clerk user. This is what enables subscription portability across devices. If a user signs in on a new phone, their premium status transfers over.
The paywall screen calls fetchOfferings() to get the available packages from RevenueCat. When a user taps "Subscribe", purchasePackage() initiates the native App Store purchase flow. RevenueCat handles the receipt validation, and the customer info listener fires with the updated entitlements.
Feature gating happens at three levels. The useSubscriptionStore exposes isPremium. The usePremiumFeature() hook returns isPremium plus a requiresPremium() function that navigates to the paywall. And limits are checked in each store: usePortfolioStore checks the 1 portfolio limit, useAlertsStore checks the 3 alert limit, useAssistantStore checks the 3 message per day limit.
Restoration works through restorePurchases() which asks RevenueCat to check the App Store for any existing entitlements tied to the user's Apple ID. This covers cases where someone reinstalls the app or gets a new device.
The customer center (RevenueCat's pre built UI) handles plan changes, cancellations, and billing info. We link to it from the profile screen rather than building our own subscription management interface.
Architecture Flow
App Launch
Purchases.configure() initializes the RevenueCat SDK with the API key. A customer info listener is registered to react to subscription changes in real time.
Auth Complete
After Clerk sign-in, Purchases.logIn(clerkUserId) links the RevenueCat anonymous ID to the authenticated user, enabling cross-device subscription portability.
Paywall Shown
Purchases.getOfferings() fetches available packages (monthly, yearly) from RevenueCat. The paywall UI renders pricing and trial info from the offering metadata.
Purchase Flow
Purchases.purchasePackage(pkg) triggers the native App Store/Play Store purchase sheet. RevenueCat validates the receipt server-side and returns updated customerInfo.
Entitlement Check
customerInfo.entitlements.active['GetFolio Pro'] is checked. If present, isPremium flips to true in the Zustand store, unlocking all gated features instantly.
Feature Gating
PremiumGate wrapper components check isPremium. Free users see a blurred overlay with upgrade CTA. Stores enforce limits: 1 portfolio, 3 alerts, 3 AI messages/day.
Key decisions
Entitlement based gating, not product based
We check for the "GetFolio Pro" entitlement, not for specific product IDs. This means we can change the product lineup (add a lifetime plan, adjust pricing, run promotions) without touching the app code. The entitlement is the abstraction layer between what the user bought and what features they get.
Free tier limits enforced on both client and server
The client checks limits before allowing actions (better UX, instant feedback). But the server also enforces them (prevents circumvention). For example, the alerts edge function checks the count server side before inserting. If a user somehow bypasses the client check, the server still blocks them. The client side check is for speed, the server side check is for security.
Dev mode always returns premium
During development, isPremium always returns true. This lets us test premium features without going through the purchase flow every time. The check is a simple __DEV__ ternary in the store. It means we never accidentally ship broken premium features because we could not test them locally.
Implementation
Store Initialization
The Zustand store configures RevenueCat on app launch and sets up a real-time listener for subscription changes.
// stores/useSubscriptionStore.ts
const PREMIUM_ENTITLEMENT = 'GetFolio Pro';
export const useSubscriptionStore = create<SubscriptionStore>((set, get) => ({
isPremium: false,
customerInfo: null,
offerings: null,
initialize: async () => {
Purchases.configure({
apiKey: process.env.EXPO_PUBLIC_REVENUECAT_API_KEY,
appUserID: undefined, // anonymous until Clerk auth
});
// Real-time listener — fires on purchase, renewal, expiration
Purchases.addCustomerInfoUpdateListener((customerInfo) => {
const isPremium =
customerInfo.entitlements.active[PREMIUM_ENTITLEMENT] !== undefined;
set({ customerInfo, isPremium });
});
await get().checkSubscription();
await get().fetchOfferings();
},
}));Clerk ↔ RevenueCat User Linking
After authentication, the Clerk user ID is linked to RevenueCat so subscriptions follow the user across devices.
// Called in app/_layout.tsx after Clerk auth completes
setUserId: async (userId: string) => {
// Links RevenueCat anonymous ID → Clerk user ID
const { customerInfo } = await Purchases.logIn(userId);
const isPremium =
customerInfo.entitlements.active[PREMIUM_ENTITLEMENT] !== undefined;
set({ customerInfo, isPremium });
},Purchase Flow
The purchase function handles the native App Store transaction, user cancellation, and error states.
purchasePackage: async (pkg: PurchasesPackage) => {
set({ isLoading: true, error: null });
try {
const { customerInfo } = await Purchases.purchasePackage(pkg);
const isPremium =
customerInfo.entitlements.active['GetFolio Pro'] !== undefined;
set({ customerInfo, isPremium, isLoading: false });
return isPremium;
} catch (error: unknown) {
if (error?.userCancelled) {
set({ isLoading: false });
return false; // user tapped cancel — not an error
}
set({ isLoading: false, error: error.message });
return false;
}
},PremiumGate Component
A wrapper that gates any screen or feature behind a premium subscription. Shows children for premium users, an upgrade prompt for free users.
// components/PremiumGate.tsx
export function PremiumGate({ feature, children, fallback }: PremiumGateProps) {
const { isPremium, isReady } = usePremiumFeature();
const router = useRouter();
if (!isReady) return null;
if (isPremium) return <>{children}</>;
if (fallback) return <>{fallback}</>;
// Default upgrade prompt
return (
<View className="flex-1 items-center justify-center px-8">
<Ionicons name="diamond" size={40} color="#1E54B7" />
<Text className="text-2xl font-bold">Premium Feature</Text>
<Text className="text-[#8E8E93] text-center">
{feature} is available with Folio Premium.
</Text>
<Pressable onPress={() => router.push('/paywall')}>
<Text className="text-white text-lg font-semibold">
Upgrade to Premium
</Text>
</Pressable>
</View>
);
}Dev Mode Premium Bypass
During development, premium is always true so all features can be tested without purchasing.
// Helper hook used across the app
export function usePremiumFeature() {
const { isPremium, isInitialized } = useSubscriptionStore();
// __DEV__ is true in development builds
const effectivePremium = __DEV__ ? true : isPremium;
return {
isPremium: effectivePremium,
isReady: __DEV__ ? true : isInitialized,
requiresPremium: !effectivePremium && isInitialized,
};
}Subscription Details Hook
A convenience hook that extracts plan type, expiration date, and renewal status from RevenueCat customer info.
export function useSubscriptionDetails() {
const { customerInfo, isPremium, getActiveSubscription } =
useSubscriptionStore();
const activeProduct = getActiveSubscription();
const entitlement =
customerInfo?.entitlements.active['GetFolio Pro'];
return {
isPremium,
isYearly: activeProduct === 'yearly',
isMonthly: activeProduct === 'monthly',
activeProduct,
expirationDate: entitlement?.expirationDate,
willRenew: entitlement?.willRenew,
};
}Custom Paywall — Loading Offerings
The paywall fetches available packages from RevenueCat and defaults to the yearly plan.
// components/CustomPaywall.tsx
const loadOfferings = async () => {
const offerings = await Purchases.getOfferings();
if (offerings.current?.availablePackages) {
const pkgs = offerings.current.availablePackages;
setPackages(pkgs);
// Default to yearly plan
const yearly = pkgs.find(
(p) => p.identifier === '$rc_annual'
|| p.identifier.includes('annual')
);
setSelectedPackage(yearly || pkgs[0]);
}
};Restore Purchases
Checks the App Store for existing entitlements tied to the user's Apple ID — covers reinstalls and new devices.
restorePurchases: async () => {
set({ isLoading: true, error: null });
try {
const customerInfo = await Purchases.restorePurchases();
const isPremium =
customerInfo.entitlements.active['GetFolio Pro'] !== undefined;
set({ customerInfo, isPremium, isLoading: false });
return isPremium;
} catch (error) {
set({ isLoading: false, error: error.message });
return false;
}
},Best Practices We Followed
Decisions informed by RevenueCat's published research and guides.
Onboarding paywall placement
RevenueCat's data shows 80% of trial starts happen the same day users first open an app, and onboarding accounts for roughly 50% of all trial starts. We followed this by showing a paywall during onboarding with a three-screen carousel that builds value before asking for the commitment. Users see what they get, get a reminder promise, then see the pricing — not the other way around.
The essential guide to mobile paywalls7-day free trial as the default offer
RevenueCat recommends free trials as the primary conversion mechanism during onboarding because they make the upgrade feel risk-free. Our paywall leads with "Start 7-day free trial" rather than a price, and we reinforce this with a timeline showing exactly what happens on Day 1, Day 5 (reminder), and Day 7 (charge). The "No payment due now" copy reduces friction further.
Supercharge your paywalls with offersYearly plan pre-selected
RevenueCat's State of Subscription Apps report shows that annual plans have significantly higher LTV and lower churn than monthly. We default-select the yearly plan on both the onboarding paywall and the main paywall. Users can still pick monthly, but the nudge toward annual aligns with the data on long-term retention.
State of Subscription Apps 2025Contextual feature gating over hard walls
Rather than blocking the entire app behind a paywall, we gate specific premium features (analytics, unlimited assets, unlimited alerts) using the PremiumGate component. Free users can explore the core experience and hit the paywall only when they try something premium. RevenueCat's research shows this contextual approach converts better than upfront hard walls because users understand the value of what they are paying for.
Contextual paywall targetingMultiple paywall surfaces
We implemented three distinct paywall experiences: an onboarding paywall (first launch), a main paywall (accessible from profile and premium gates), and inline upgrade prompts (PremiumBadge, PremiumButton). RevenueCat recommends not relying on a single paywall surface — different users hit different touchpoints, and each is an opportunity to convert.
Paywall placement optimizationRestore purchases always accessible
Every paywall and the customer center include a "Restore purchases" button. This is an App Store requirement, but RevenueCat also highlights it as a trust signal. Users who see they can restore feel more confident purchasing because they know they will not lose access if they switch devices or reinstall.
5 overlooked paywall improvementsEntitlements over product IDs
RevenueCat's SDK is built around entitlements as the abstraction layer. We check for "GetFolio Pro" instead of specific product IDs. This means we can run pricing experiments, add new plans, or change trial lengths entirely from the RevenueCat dashboard without shipping an app update. The blog emphasizes this as the key to iterating on monetization quickly.
Paywalls study guide