Subscriptions are the main business model for mobile apps, and implementing them directly with StoreKit and Google Play Billing is notoriously painful: receipts, renewals, grace periods, refunds, and cross-platform status. RevenueCat wraps all of it in one SDK and a dashboard, and it works smoothly with Expo.
How it fits together
- App Store Connect and Google Play Console: create your products (for example
pro_monthlyandpro_yearly). - RevenueCat dashboard: group products into an entitlement (
pro) and an offering (what the paywall shows). - Your app: fetch the offering, purchase a package, and check if
prois active.
Your code only cares about entitlements, so you can change prices, run trials, and A/B test paywalls without shipping an update.
Install
npx expo install react-native-purchases react-native-purchases-ui
react-native-purchases is the core SDK. react-native-purchases-ui adds prebuilt native paywalls.
In Expo Go, the SDK automatically switches to Preview API Mode with mock responses, so you can build UI. Real purchases need a development build:
eas build --profile development --platform ios
Configure on app start
Use the platform-specific public API keys from the RevenueCat dashboard:
// app/_layout.tsx
import { useEffect } from "react"
import { Platform } from "react-native"
import Purchases from "react-native-purchases"
import { Stack } from "expo-router"
export default function RootLayout() {
useEffect(() => {
Purchases.configure({
apiKey: Platform.OS === "ios"
? process.env.EXPO_PUBLIC_RC_IOS_KEY!
: process.env.EXPO_PUBLIC_RC_ANDROID_KEY!,
})
}, [])
return <Stack />
}
These are public SDK keys, designed to ship in the app, so EXPO_PUBLIC_ is fine here (unlike LLM provider keys). If you have accounts, call Purchases.logIn(userId) after sign-in so purchases follow the user across devices.
Check entitlements
import Purchases from "react-native-purchases"
export async function isPro() {
const customerInfo = await Purchases.getCustomerInfo()
return typeof customerInfo.entitlements.active["pro"] !== "undefined"
}
Wrap it in a hook and gate features with it. Keep the result in your client store (Zustand) and refresh it with Purchases.addCustomerInfoUpdateListener.
Option A: show a native paywall
The fastest route is a RevenueCat paywall designed in the dashboard:
import RevenueCatUI, { PAYWALL_RESULT } from "react-native-purchases-ui"
export async function showPaywall() {
const result = await RevenueCatUI.presentPaywallIfNeeded({
requiredEntitlementIdentifier: "pro",
})
return result === PAYWALL_RESULT.PURCHASED || result === PAYWALL_RESULT.RESTORED
}
presentPaywallIfNeeded only shows the paywall if the user doesn't already have pro.
Option B: build your own paywall
For a fully branded paywall, fetch the current offering and render packages yourself:
import { useEffect, useState } from "react"
import { Pressable, Text, View } from "react-native"
import Purchases, { type PurchasesPackage } from "react-native-purchases"
export function Paywall({ onUnlocked }: { onUnlocked: () => void }) {
const [packages, setPackages] = useState<PurchasesPackage[]>([])
useEffect(() => {
Purchases.getOfferings().then((o) => setPackages(o.current?.availablePackages ?? []))
}, [])
async function buy(pkg: PurchasesPackage) {
try {
const { customerInfo } = await Purchases.purchasePackage(pkg)
if (customerInfo.entitlements.active["pro"]) onUnlocked()
} catch (e: any) {
if (!e.userCancelled) console.warn(e)
}
}
return (
<View className="gap-3 p-6">
{packages.map((pkg) => (
<Pressable key={pkg.identifier} onPress={() => buy(pkg)} className="rounded-2xl border p-4">
<Text className="font-semibold">{pkg.product.title}</Text>
<Text>{pkg.product.priceString}</Text>
</Pressable>
))}
<Pressable onPress={() => Purchases.restorePurchases()}>
<Text className="text-center underline">Restore purchases</Text>
</Pressable>
</View>
)
}
Always include Restore purchases. App Review requires it.
Testing purchases
- iOS: use a sandbox tester account in a development build, or StoreKit configuration files for local testing.
- Android: upload a build to an internal testing track, add license testers, and install from the Play Store link.
- Sandbox subscriptions renew quickly (minutes, not months), so you can test renewals and expirations fast.
Paywall best practices
- Show value before the price. List three concrete benefits.
- Highlight the yearly plan with the per-month equivalent and savings.
- Offer a free trial where it fits your product.
- Don't block onboarding with a hard paywall unless you've tested it.
- Present the paywall in a bottom sheet or full-screen modal, and celebrate a purchase with confetti and a success haptic.
Ship it
Products need to be approved in App Store Connect along with your first build that uses them. Follow our Expo App Store and Google Play publishing guide for the release, and use EAS Update to iterate on paywall UI afterwards.