Skip to main content

How to Add Subscriptions to an Expo App with RevenueCat

Paywall, entitlements, restore and testing for Expo SDK projects, without fighting StoreKit and Play Billing

Updated 8 min readMarkdown
Part of seriesShipping Expo Apps to Production
How to Add Subscriptions to an Expo App with RevenueCat

Subscriptions are the business model for a lot of mobile apps, and they are one of the few features where a small mistake costs real money. The good news is that the code for an Expo app is short. Most of the work is in the store setup, the build, and testing.

This guide adds auto-renewing subscriptions to an Expo app with RevenueCat: installing the SDK, showing a paywall, checking access, restoring purchases, and testing before you ship.

Why use RevenueCat instead of talking to the stores directly?

Apple's StoreKit and Google Play Billing are different APIs with different receipt formats, renewal events and edge cases. RevenueCat sits in between. Your app calls one SDK, RevenueCat validates purchases with each store, and your code only asks one question: does this user have the entitlement?

Three RevenueCat terms are worth learning first:

Term What it is Example
Product A single item you created in App Store Connect or Google Play Console pro_monthly, pro_yearly
Entitlement The level of access a product unlocks pro
Offering The set of packages you show on a paywall, configurable from the dashboard default with monthly and yearly packages

Your app code should check entitlements, not product IDs. That way you can change prices or add a yearly plan without shipping a new build.

Does RevenueCat work in Expo Go?

Partly. According to RevenueCat's Expo installation guide, react-native-purchases has a built-in Preview API Mode for Expo Go. It swaps native calls for JavaScript mocks, so you can build and preview your paywall UI, but real purchases do not work in Expo Go. To test actual purchases you need a development build.

Step 1: Install the packages

npx expo install react-native-purchases react-native-purchases-ui expo-dev-client
  • react-native-purchases is the core SDK.
  • react-native-purchases-ui provides prebuilt paywalls and the Customer Center.
  • expo-dev-client is needed for a development build.

After installing, do a full native build. RevenueCat warns that reloading without rebuilding leads to errors such as `new NativeEventEmitter()` requires a non-null argument.

eas build --profile development --platform ios
eas build --profile development --platform android

Step 2: Configure the SDK once at startup

Each platform has its own public API key in the RevenueCat dashboard. Configure the SDK once, as early as possible, for example in your root layout:

// app/_layout.tsx
import { useEffect } from 'react';
import { Platform } from 'react-native';
import { Stack } from 'expo-router';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';

export default function RootLayout() {
  useEffect(() => {
    if (__DEV__) Purchases.setLogLevel(LOG_LEVEL.VERBOSE);

    Purchases.configure({
      apiKey: Platform.OS === 'ios'
        ? process.env.EXPO_PUBLIC_RC_APPLE_KEY!
        : process.env.EXPO_PUBLIC_RC_GOOGLE_KEY!,
    });
  }, []);

  return <Stack />;
}

These are public SDK keys, so EXPO_PUBLIC_ variables are fine here. Never ship a RevenueCat secret key in the app.

If your app has its own accounts, call Purchases.logIn(userId) after sign-in so purchases follow the user across devices, and Purchases.logOut() on sign-out.

Step 3: Show a paywall

You have two options.

Option A: RevenueCat's prebuilt paywall. You design it in the dashboard and present it from code. The presentPaywallIfNeeded helper only shows the paywall when the user does not already have the entitlement:

import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui';

export async function requirePro(): Promise<boolean> {
  const result = await RevenueCatUI.presentPaywallIfNeeded({
    requiredEntitlementIdentifier: 'pro',
  });

  switch (result) {
    case PAYWALL_RESULT.PURCHASED:
    case PAYWALL_RESULT.RESTORED:
    case PAYWALL_RESULT.NOT_PRESENTED: // already has access
      return true;
    default: // CANCELLED or ERROR
      return false;
  }
}

Note that NOT_PRESENTED here means the user already has pro, so treat it as access granted. The paywall docs also show a <RevenueCatUI.Paywall /> component if you would rather embed the paywall in your own screen.

Option B: your own UI. Fetch the current offering and render the packages yourself:

import { useEffect, useState } from 'react';
import { Pressable, Text, View } from 'react-native';
import Purchases, { PurchasesPackage } from 'react-native-purchases';

export function PlanPicker({ onUnlocked }: { onUnlocked: () => void }) {
  const [packages, setPackages] = useState<PurchasesPackage[]>([]);

  useEffect(() => {
    Purchases.getOfferings().then((offerings) => {
      setPackages(offerings.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>
      {packages.map((pkg) => (
        <Pressable key={pkg.identifier} onPress={() => buy(pkg)}>
          <Text>{pkg.product.title}: {pkg.product.priceString}</Text>
        </Pressable>
      ))}
    </View>
  );
}

Two details matter here:

  • Show the store's price string, not a hard-coded price. Prices differ by country and currency.
  • Ignore cancellations. When the user closes the store sheet, purchasePackage throws with userCancelled: true. That is not an error worth showing.

Step 4: Check access anywhere in the app

Wrap the entitlement check in a hook and listen for updates, so renewals, expirations and restores update the UI without a restart:

import { useEffect, useState } from 'react';
import Purchases, { CustomerInfo } from 'react-native-purchases';

export function useIsPro() {
  const [isPro, setIsPro] = useState(false);

  useEffect(() => {
    const update = (info: CustomerInfo) =>
      setIsPro(typeof info.entitlements.active['pro'] !== 'undefined');

    Purchases.getCustomerInfo().then(update).catch(() => {});
    Purchases.addCustomerInfoUpdateListener(update);
    return () => {
      Purchases.removeCustomerInfoUpdateListener(update);
    };
  }, []);

  return isPro;
}

For anything that has real value on the server, such as premium API calls, check the entitlement on your backend too. A client-side check is only a UI gate.

Step 5: Add a Restore Purchases button

Both stores expect a way for users to restore purchases, for example after reinstalling or moving to a new phone. RevenueCat's restoring purchases guide says restorePurchases should only be called from a user action, such as tapping a "Restore" button, because it can trigger an OS sign-in prompt.

async function onRestorePress() {
  try {
    const info = await Purchases.restorePurchases();
    const restored = typeof info.entitlements.active['pro'] !== 'undefined';
    alert(restored ? 'Purchases restored' : 'No active subscription found');
  } catch (e) {
    alert('Restore failed. Please try again.');
  }
}

Put it on the paywall and in settings. Do not call it automatically on launch.

How do you test subscriptions before release?

RevenueCat's sandbox testing docs recommend a three-stage workflow:

Stage Environment What you need
Building the flow RevenueCat Test Store Only the Test Store API key. No store accounts. Test purchases update CustomerInfo and entitlements like real ones.
Before review Apple sandbox / Google Play testing Your real platform API keys and sandbox test accounts
Production Live stores Platform API keys only. Never ship the Test Store key.

Google Play has a few extra requirements. Per RevenueCat's Play Store sandbox guide, you need to upload a signed build to a closed testing track, add your test account as a license tester, and open the testing opt-in link, or products will not load.

Sandbox renewals are also accelerated. On Google Play, a monthly subscription renews every 5 minutes and a yearly one every 30 minutes, with at most six renewals. This is handy for testing expiry and renewal handling in one sitting.

Common problems and their fixes

Symptom Likely cause
Offerings are empty Products not attached to the offering in the dashboard, or store products not yet approved or active
Purchases do nothing in Expo Go Expected. Expo Go only runs Preview API Mode. Use a development build.
NativeEventEmitter error after install You installed the package but did not rebuild the native app
Android products never load Build not on a testing track, tester not a license tester, or opt-in link not opened
Paid user sees the paywall again Checking product IDs instead of the entitlement, or not calling logIn with your user ID

Where this fits in a real app

The paywall usually comes after the core screens, auth and onboarding exist, because you need something worth paying for. If you are still building those screens, RapidNative generates a standard Expo project (Expo Router, TypeScript, NativeWind) from a plain-English prompt, which you can export and add RevenueCat to exactly as shown here. Disclosure: I work on RapidNative.

Wrapping up

Adding subscriptions to an Expo app comes down to a few rules: use a development build, configure the SDK once, gate features on entitlements rather than product IDs, show store prices, give users a Restore button, and test with the Test Store first and the platform sandboxes before review.

Which part of in-app subscriptions gave you the most trouble: store setup, testing, or keeping access in sync with your backend?

No comments yet. Be the first to comment.

Shipping Expo Apps to Production

Part 2 of 3

Practical, production-focused guides for React Native and Expo developers: OTA updates, EAS builds, push notifications, auth and payments. What to set up, what breaks, and how to ship with confidence.

Up nextPasswordless Sign-In in Expo with Supabase Email OTP CodesSix-digit codes instead of magic links: no deep links, iOS autofill, resend cooldown and the SMTP setting that breaks it for real users