React Native In-App Purchases with Expo: RevenueCat and expo-iap Compared

Learn how to add in-app purchases and subscriptions to your React Native Expo app. Compares RevenueCat and expo-iap with full code examples, paywall UI, store configuration, testing, and subscription lifecycle management.

Monetizing a React Native app through in-app purchases and subscriptions is one of the most reliable ways to build recurring revenue. But let's be honest—getting IAP working with Expo has historically been a headache. Between native modules, development builds, platform-specific billing APIs, and the joy of receipt validation, there's a lot that can go wrong.

The good news? In 2026, the ecosystem is in a much better place. Two libraries have emerged as the go-to options for Expo developers: RevenueCat (via react-native-purchases) and expo-iap. I've worked with both, and each has its sweet spot.

This guide walks you through both approaches—from installation and store configuration to building a paywall, testing purchases, and managing subscription lifecycles. Everything here uses the Expo managed workflow with development builds.

Choosing a Library: RevenueCat vs expo-iap

Before writing any code, you need to pick the right tool. Both libraries are officially recommended by Expo for in-app purchase functionality and work with Continuous Native Generation (CNG) and Config Plugins. So you're in good hands either way.

RevenueCat (react-native-purchases)

RevenueCat is an open-source SDK wrapping StoreKit 2 (iOS) and Google Play Billing (Android) behind a unified API. It comes with a managed backend service that handles receipt validation, entitlement management, analytics, and cross-platform subscription sync. The companion package react-native-purchases-ui gives you prebuilt native paywall components you can customize through their dashboard—no app update required.

expo-iap

expo-iap is an Expo Module (migrated from react-native-iap) with tight integration into the Expo ecosystem. It gives you direct access to native purchase APIs through React hooks, but it leaves server-side receipt validation and entitlement management entirely up to you.

That trade-off is the key difference.

Side-by-Side Comparison

FeatureRevenueCatexpo-iap
Cross-platform (iOS/Android)YesYes
Web billing supportYes (via Stripe)No
Server-side receipt validationBuilt-inDIY
Analytics dashboardBuilt-inNot included
Prebuilt paywall UIYesNo
A/B testing for pricingYesNo
Expo Go previewMock API modeNot available
CostFree tier + paid plansFree / open-source
Best forSubscription-heavy appsSimple one-time purchases

My recommendation: Go with RevenueCat if your app relies on subscriptions, needs analytics, or targets multiple platforms including web. Use expo-iap if you have straightforward one-time purchases and want full control without depending on a third-party backend.

Prerequisites

Regardless of which library you choose, you'll need:

  • An Expo project using SDK 52 or later
  • A development build (Expo Go doesn't support native IAP modules—RevenueCat offers a mock preview mode, but real purchases need a dev build)
  • An Apple Developer account with App Store Connect access (for iOS)
  • A Google Play Console account with a published or internal testing app (for Android)
  • EAS CLI installed globally: npm install -g eas-cli

Option A: In-App Purchases with RevenueCat

This section covers the full setup from installation to a working subscription flow. It's more steps than you might expect, but each one is straightforward.

Step 1: Install Dependencies

Install the RevenueCat SDK packages along with the Expo dev client:

npx expo install expo-dev-client react-native-purchases react-native-purchases-ui

expo-dev-client enables development builds. react-native-purchases handles the core purchase logic, and react-native-purchases-ui adds those prebuilt paywall components I mentioned earlier.

Step 2: Create a RevenueCat Account and Configure Projects

Sign up at revenuecat.com and create a new project. You'll get separate API keys for iOS, Android, and web. Store these securely—you'll reference them via environment variables.

Add your API keys to your .env file:

EXPO_PUBLIC_RC_IOS_KEY=appl_your_ios_key_here
EXPO_PUBLIC_RC_ANDROID_KEY=goog_your_android_key_here

Step 3: Initialize the SDK

Create a provider component that initializes RevenueCat when your app launches. This is where the magic happens—it fetches offerings and customer info on startup so the rest of your app can just read from context:

// src/providers/RevenueCatProvider.tsx
import React, { createContext, useContext, useEffect, useState } from 'react';
import { Platform } from 'react-native';
import Purchases, {
  LOG_LEVEL,
  CustomerInfo,
  PurchasesOfferings,
} from 'react-native-purchases';

interface RevenueCatContextType {
  customerInfo: CustomerInfo | null;
  offerings: PurchasesOfferings | null;
  isPro: boolean;
}

const RevenueCatContext = createContext<RevenueCatContextType>({
  customerInfo: null,
  offerings: null,
  isPro: false,
});

export function RevenueCatProvider({ children }: { children: React.ReactNode }) {
  const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null);
  const [offerings, setOfferings] = useState<PurchasesOfferings | null>(null);

  useEffect(() => {
    async function init() {
      Purchases.setLogLevel(LOG_LEVEL.DEBUG);

      const apiKey = Platform.select({
        ios: process.env.EXPO_PUBLIC_RC_IOS_KEY,
        android: process.env.EXPO_PUBLIC_RC_ANDROID_KEY,
      });

      if (!apiKey) return;

      await Purchases.configure({ apiKey });

      const [fetchedOfferings, fetchedInfo] = await Promise.all([
        Purchases.getOfferings(),
        Purchases.getCustomerInfo(),
      ]);

      setOfferings(fetchedOfferings);
      setCustomerInfo(fetchedInfo);
    }

    init();
  }, []);

  const isPro = customerInfo?.entitlements?.active?.premium !== undefined;

  return (
    <RevenueCatContext.Provider value={{ customerInfo, offerings, isPro }}>
      {children}
    </RevenueCatContext.Provider>
  );
}

export const useRevenueCat = () => useContext(RevenueCatContext);

Then wrap your root layout with this provider:

// app/_layout.tsx
import { RevenueCatProvider } from '../src/providers/RevenueCatProvider';

export default function RootLayout() {
  return (
    <RevenueCatProvider>
      {/* your Stack or Tabs navigator */}
    </RevenueCatProvider>
  );
}

Step 4: Build a Paywall Screen

With offerings loaded, you can display available packages and handle purchases. Here's a basic paywall—you'll probably want to make it fancier, but the bones are solid:

// src/screens/PaywallScreen.tsx
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, Alert, ActivityIndicator, StyleSheet } from 'react-native';
import Purchases from 'react-native-purchases';
import { useRevenueCat } from '../providers/RevenueCatProvider';

export default function PaywallScreen() {
  const { offerings, isPro } = useRevenueCat();
  const [loading, setLoading] = useState(false);

  if (isPro) {
    return (
      <View style={styles.container}>
        <Text style={styles.title}>You are a Pro member!</Text>
      </View>
    );
  }

  const packages = offerings?.current?.availablePackages ?? [];

  async function handlePurchase(pkg: typeof packages[number]) {
    try {
      setLoading(true);
      const { customerInfo } = await Purchases.purchasePackage(pkg);
      if (customerInfo.entitlements.active.premium) {
        Alert.alert('Success', 'You now have premium access!');
      }
    } catch (error: any) {
      if (!error.userCancelled) {
        Alert.alert('Error', error.message);
      }
    } finally {
      setLoading(false);
    }
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Unlock Premium</Text>
      {packages.map((pkg) => (
        <TouchableOpacity
          key={pkg.identifier}
          style={styles.packageButton}
          onPress={() => handlePurchase(pkg)}
          disabled={loading}
        >
          <Text style={styles.packageTitle}>{pkg.product.title}</Text>
          <Text style={styles.packagePrice}>{pkg.product.priceString}</Text>
        </TouchableOpacity>
      ))}
      {loading && <ActivityIndicator size="large" />}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 28, fontWeight: 'bold', textAlign: 'center', marginBottom: 24 },
  packageButton: {
    backgroundColor: '#4F46E5',
    borderRadius: 12,
    padding: 16,
    marginBottom: 12,
    alignItems: 'center',
  },
  packageTitle: { color: '#fff', fontSize: 18, fontWeight: '600' },
  packagePrice: { color: '#E0E7FF', fontSize: 16, marginTop: 4 },
});

Step 5: Use the Prebuilt Paywall (Alternative)

Don't feel like building a custom paywall UI? Fair enough. RevenueCat provides prebuilt native paywall components via react-native-purchases-ui:

import RevenueCatUI from 'react-native-purchases-ui';

function PremiumScreen() {
  return <RevenueCatUI.Paywall />;
}

You can configure the layout, colors, and copy of this paywall directly in the RevenueCat dashboard—without shipping a new app build. Honestly, for most apps, this is the faster path to production.

Step 6: Check Entitlements Throughout Your App

Use the useRevenueCat hook anywhere you need to gate premium features:

import { useRevenueCat } from '../providers/RevenueCatProvider';

function PremiumFeature() {
  const { isPro } = useRevenueCat();

  if (!isPro) {
    return <UpgradePrompt />;
  }

  return <PremiumContent />;
}

Clean and simple. No prop drilling, no complex state management.

Option B: In-App Purchases with expo-iap

If you want a lighter-weight solution without a third-party backend, expo-iap gives you direct access to native purchase APIs through React hooks. It's more work on your end, but you get full control.

Step 1: Install expo-iap

npx expo install expo-iap expo-dev-client

Step 2: Configure Product IDs

Define your product identifiers. These must match the product IDs you create in App Store Connect and Google Play Console exactly (this trips people up more often than you'd think):

// src/config/products.ts
import { Platform } from 'react-native';

export const PRODUCT_IDS = Platform.select({
  ios: ['com.yourapp.premium_monthly', 'com.yourapp.premium_yearly'],
  android: ['premium_monthly', 'premium_yearly'],
  default: [],
})!;

Step 3: Use the useIAP Hook

expo-iap provides a useIAP hook that encapsulates the full purchase lifecycle:

// src/screens/StoreScreen.tsx
import React, { useEffect } from 'react';
import { View, Text, TouchableOpacity, Alert, StyleSheet } from 'react-native';
import { useIAP } from 'expo-iap';
import { PRODUCT_IDS } from '../config/products';

export default function StoreScreen() {
  const {
    connected,
    products,
    currentPurchase,
    getProducts,
    requestPurchase,
  } = useIAP();

  useEffect(() => {
    if (connected) {
      getProducts(PRODUCT_IDS);
    }
  }, [connected]);

  useEffect(() => {
    if (currentPurchase) {
      // Validate the receipt on your backend here
      Alert.alert('Purchase Successful', `Product: ${currentPurchase.productId}`);
    }
  }, [currentPurchase]);

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Available Products</Text>
      {products.map((product) => (
        <TouchableOpacity
          key={product.productId}
          style={styles.button}
          onPress={() => requestPurchase({ productId: product.productId })}
        >
          <Text style={styles.name}>{product.title}</Text>
          <Text style={styles.price}>{product.localizedPrice}</Text>
        </TouchableOpacity>
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, justifyContent: 'center' },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20, textAlign: 'center' },
  button: {
    backgroundColor: '#10B981',
    padding: 16,
    borderRadius: 12,
    marginBottom: 12,
    alignItems: 'center',
  },
  name: { color: '#fff', fontSize: 18, fontWeight: '600' },
  price: { color: '#D1FAE5', fontSize: 16, marginTop: 4 },
});

Important: With expo-iap, you're responsible for validating purchase receipts on your own backend server. Never trust client-side purchase validation alone—always verify receipts server-side to prevent fraud. This is non-negotiable.

Configuring Products in App Store Connect (iOS)

Before your app can sell anything, you need to create products in each store. Let's start with Apple.

  1. Log into App Store Connect and select your app.
  2. Navigate to Monetization > Subscriptions (for auto-renewable) or Monetization > In-App Purchases (for consumables/non-consumables).
  3. Click the + button to create a new subscription group (e.g., "Premium").
  4. Add subscription products within the group. Set a Reference Name (internal) and Product ID (must match your code exactly, e.g., com.yourapp.premium_monthly).
  5. Configure pricing, duration, free trial offers, and localized display names.
  6. For RevenueCat: copy the App-Specific Shared Secret from App Information > App-Specific Shared Secret and paste it into your RevenueCat project settings.

For local development without App Store Connect, create a StoreKit Configuration File in Xcode. This lets you test purchase flows entirely offline during development—super handy when you don't want to wait for store review.

Configuring Products in Google Play Console (Android)

  1. Open the Google Play Console and select your app.
  2. Navigate to Monetize > Products > Subscriptions (or In-app products for one-time purchases).
  3. Click Create subscription and define the Product ID (e.g., premium_monthly), name, and description.
  4. Add a base plan with pricing and billing period.
  5. For RevenueCat: generate a service account key for server-to-server communication and upload it to your RevenueCat dashboard under Project Settings > Google Play.

Note: Google Play requires your app to be published to at least an internal testing track before you can test real purchases. You'll need to upload an AAB via EAS Build first.

Building and Testing Your Development Build

In-app purchases require native code, so you can't use Expo Go. You'll need a development build.

Configure eas.json

{
  "cli": { "version": ">= 16.0.0" },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "ios-simulator": {
      "extends": "development",
      "ios": { "simulator": true }
    },
    "preview": {
      "distribution": "internal"
    },
    "production": {}
  }
}

Run Your Build

# Build for iOS simulator
eas build --profile ios-simulator --platform ios

# Build for Android device
eas build --profile development --platform android

# Or build locally
npx expo run:ios
npx expo run:android

Testing Purchases

Each platform has its own sandbox testing environment. Here's what you need to know:

  • iOS: Use a Sandbox Apple ID (created in App Store Connect under Users and Access > Sandbox Testers). Sign into this account on your test device under Settings > App Store > Sandbox Account. One thing that catches people off guard—sandbox subscriptions renew at an accelerated rate. A monthly subscription renews every 5 minutes.
  • Android: Add test accounts as license testers in Google Play Console under Settings > License testing. Test purchases made by these accounts won't be charged.
  • RevenueCat Test Store: RevenueCat offers a built-in Test Store that works without any App Store or Play Console configuration. It uses a dedicated Test Store API key and simulates real purchase and subscription behavior, including renewals and cancellations. This even works in Expo Go—which makes it great for quick iteration.

Handling Subscription Lifecycle Events

Subscriptions aren't one-and-done transactions. You need to handle renewals, cancellations, billing issues, and upgrades/downgrades. This is where things get real.

With RevenueCat

RevenueCat handles most of the lifecycle complexity for you. Listen for customer info updates to react to subscription changes in real time:

import Purchases from 'react-native-purchases';

// Listen for real-time subscription changes
Purchases.addCustomerInfoUpdateListener((info) => {
  const isActive = info.entitlements.active.premium !== undefined;
  // Update your app state accordingly
});

// Restore purchases (e.g., on a new device)
async function restorePurchases() {
  try {
    const info = await Purchases.restorePurchases();
    return info.entitlements.active.premium !== undefined;
  } catch (error) {
    console.error('Restore failed:', error);
    return false;
  }
}

For server-side events like refunds, billing retries, and subscription expirations, configure webhooks in the RevenueCat dashboard to notify your backend.

With expo-iap

You'll need to implement lifecycle handling yourself. Set up a backend endpoint that receives App Store Server Notifications (v2) and Google Play Real-time Developer Notifications via Cloud Pub/Sub. Your backend should update the user's entitlement status in your database whenever subscription events come in.

It's doable, but it's a fair amount of plumbing.

Production Checklist

Before shipping your app with in-app purchases, make sure you've covered these bases:

  • Receipt validation: If using expo-iap, implement server-side validation. RevenueCat handles this automatically.
  • Restore purchases: Both App Store and Google Play require a "Restore Purchases" button. Users switching devices need to recover their purchases—and reviewers will look for this.
  • Subscription management: Provide a way for users to manage or cancel subscriptions. Link to the platform-specific subscription settings page.
  • Offline handling: Cache the user's entitlement status locally (e.g., in MMKV or AsyncStorage) so premium features remain accessible when offline.
  • Error states: Handle network failures, payment declines, and user cancellations gracefully. Nobody wants a blank screen when a payment fails.
  • Legal compliance: Include terms of service and privacy policy links on your paywall. Both Apple and Google require this, and your app will get rejected without them.

Common Pitfalls and How to Avoid Them

  • Testing in Expo Go: Real purchases will never work in Expo Go. RevenueCat's mock preview mode lets you test UI flows, but always validate with a development build before release.
  • Mismatched product IDs: The product ID in your code must exactly match what you configured in App Store Connect or Google Play Console. This is the single most common source of "product not found" errors. Double-check, then check again.
  • Forgetting to finish transactions: With expo-iap, you must call finishTransaction after processing a purchase. Unfinished transactions will block future purchases—and the error messages are not particularly helpful.
  • Not handling upgrades/downgrades: When a user switches subscription tiers, both platforms prorate the change differently. Test these flows thoroughly.
  • Ignoring grace periods: Both stores offer billing grace periods where the subscription stays active while payment issues are resolved. Make sure your entitlement check accounts for this, or you'll accidentally lock out paying users.

Frequently Asked Questions

Can I test in-app purchases in Expo Go?

Not with real purchases. Expo Go doesn't support native IAP modules. However, RevenueCat's react-native-purchases includes a Preview API Mode that automatically activates in Expo Go, providing mock purchase responses so you can test UI flows. For real purchase testing, you need a development build using EAS or local native tooling.

What's the difference between RevenueCat and expo-iap?

RevenueCat is a full-service platform with managed receipt validation, analytics, cross-platform subscription sync, and prebuilt paywall UI. expo-iap is a lightweight open-source library that gives you direct access to native purchase APIs but requires you to build your own backend for receipt validation and entitlement management. Choose RevenueCat for subscription-heavy apps; go with expo-iap for simple one-time purchases where you want full control.

Do I need a backend server for in-app purchases?

With RevenueCat, no—their servers handle receipt validation, entitlement management, and webhook processing. With expo-iap, yes—you need a backend to validate purchase receipts securely and track subscription status. Never rely solely on client-side validation.

How do I handle subscription renewals and cancellations?

RevenueCat manages this automatically through its CustomerInfo listener and server-side webhook system. With expo-iap, you'll need to configure Apple's App Store Server Notifications v2 and Google Play Real-time Developer Notifications to send events to your backend, which then updates the user's entitlement status in your database.

Can I offer subscriptions on iOS, Android, and web from a single codebase?

Yes, with RevenueCat. Their Web Billing feature uses Stripe as the payment processor for web purchases, while iOS and Android use native store billing. Entitlements are shared across all platforms, so a user who subscribes on web gets premium access on mobile and vice versa. expo-iap only supports iOS and Android.

About the Author Editorial Team

Our team of expert writers and editors.