# 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](https://www.revenuecat.com/docs/getting-started/installation/expo), `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

```bash
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 ``.

```bash
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:

```tsx
// 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:

```tsx
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](https://www.revenuecat.com/docs/tools/paywalls/displaying-paywalls) 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:

```tsx
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:

```tsx
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](https://www.revenuecat.com/docs/getting-started/restoring-purchases) 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.

```tsx
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](https://www.revenuecat.com/docs/test-and-launch/sandbox) 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](https://www.revenuecat.com/docs/test-and-launch/sandbox/google-play-store), 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](https://rapidnative.com/?utm_source=hashnode&utm_medium=blog&utm_campaign=expo-subscriptions-revenuecat) 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?

