Build a single Expo app with subscriptions on iOS, Android, and Web using RevenueCat
Use one React Native codebase and RevenueCat’s Web Billing SDK to support subscriptions across platforms in 30 mins

With the launch of RevenueCat Web Billing and the web SDK , it’s now easier than ever to offer subscriptions across iOS, Android, and Web — all from a single codebase. No more platform silos: users can subscribe on one platform and unlock premium access everywhere.
In this tutorial we are going to make use of RevenueCat Web billing to build a simple React Native app with Expo for iOS, Android and Web, with subscriptions seamlessly working across all three platforms. We’ll minimize platform-specific code while walking through the key setup steps. If you have not already set up a RevenueCat account, we will start with that and configure the products for app three platforms first.
Step 1 – Configure your subscription products
To begin monetizing your app across iOS, Android, and Web with RevenueCat, you’ll first need to create a RevenueCat account. This account will serve as the central hub for managing your subscriptions, products, and customer data.
- Visit the RevenueCat website.
- Locate the “Sign Up” or “Get Started” button.
- Follow the on-screen instructions to create your account, providing the necessary information such as your email address, password, and company details.
- Verify your email address when prompted.
- Once your account is created, you’ll have access to the RevenueCat dashboard where you can begin configuring your products and integrating the SDK into your Expo app.
For setting up a connection between App Store Connect and Revenuecat follow this guide. For Android and Play Console, we have a similar one here. Web Billing requires a Stripe account, so also create one and follow this guide linking RevenueCat and Stripe. Once you have set up the platforms your app is available, install the react-native-purchases library in your project by following this guide.
Configure iOS products in App Store Connect
To set up a one-month subscription on iOS, start by logging into App Store Connect and navigating to My Apps. Select the app you want to work on, then go to the Features tab and open the In-App Purchases section. Click the + button and choose New Subscription.
Create a subscription with a reference name 1_month_premium_ios , and use the same value for the Product ID. Assign this subscription to a subscription group — if you don’t have one yet, you’ll need to create it now. For the duration, select 1 Month, and choose a pricing tier that reflects the monthly cost you want to charge, such as Tier 3.
You’ll also need to provide localized display names, a screenshot of the subscription in the app (during testing, an empty image is fine), and descriptions for the subscription, then save the product. Remember, you’ll need to submit the subscription for review when you submit your app and then it needs to have up to date and correct information about your subscriptions.
Next, head over to your RevenueCat dashboard. Under the Products tab, click + New to add the same subscription. Use the exact same Product ID 1_month_premium_ios, set the store to App Store, and the type to Subscription. Finally, link this product to an entitlement — for example, premium — which you’ll later use in your app to check if the user has access.
For more information about product setup of iOS and App Store Connect, check out RevenueCat’s iOS product setup guide
Configure Android products in Google Play Console
If you haven’t done so already, follow the Google Play Store section of RevenueCat’s docs to make RevenueCat and Google Play communicate with each other. After everything looks good there, navigate to Google Play Console’s ‘App Applications’ page and select the app you are working on. From the sidebar, select the Products dropdown and Subscriptions.
After clicking Create, you need to provide a name and a Product ID for the subscription. RevenueCat uses this unique Product ID to sync the subscription. You should use a naming that is something like <app>_<entitlement>_<version>, since Product IDs cannot be used again, even after deleting.
The next step is adding a base plan, which will define the billing period, price and renewal type. This should match the details of the subscription we created for the iOS version, so 1 month and auto-renewing. After this, you can start using your subscription in RevenueCat.
For more information about product setup for Android and Google Play, check out RevenueCat’s documentation for Google Play product setup.
Configure Web Billing products in RevenueCat
We can easily create new Web Billing products through the RevenueCat dashboard. Go to your project’s settings, and under “Products” click on “New”, and then select your Web Billing App. The fields should be pretty self explanatory so go ahead and fill the details for a similar monthly subscription we had for iOS and Android. If you need any help, see the guide for configuring Web Billing Product.
Once you’ve created a RevenueCat account and set up subscriptions for iOS, Android, and Web. Each product is linked to the same premium entitlement in RevenueCat, ensuring shared access across platforms. With Stripe connected for Web Billing and all products configured in your dashboard, you’re now ready to move on to implementation.
Step 2 – Add in-app purchase to your React Native app
Note: Expo Go does not support react-native-purchases as it contains native code. In order to make your React Native project support this package, you need to use a development build. More information about development builds here.
Now that we have everything configured from the platform side, it’s time to look at the code part that powers all this. Since we are using Expo, we can easily ship code for three platforms:
- For iOS and Android, we will use the react-native-purchases package
- For the web version @revenuecat/purchases-js package.
Since the first one is not supported on web, and likewise the second is not supported on the web version, we need to use React Native’s platform specific modules capabilities, to be exact the platform specific extensions. The Metro bundler in React Native can automatically pick up the right file for the right platform by using extensions such as .ios, .android, .native., and .web. In our case we don’t need platform specific code for iOS and Android, so we will just use the .native for both iOS and Android, and .web for the web parts.
Configure react-native-purchases
Create a file called purchases.native.ts and paste this inside of it:
1import { usePaymentsConfig } from "@/hooks/usePaymentsConfig";
2import { useState } from "react";
3import { useEffect } from "react";
4import Purchases, {
5 PurchasesPackage,
6 CustomerInfo,
7} from "react-native-purchases";
8
9export const initializePayments = async (apiKey: string) => {
10 await Purchases.configure({
11 apiKey,
12 appUserID: "perttu+3@lahteenlahti.com",
13 });
14};
15
16export const usePackages = () => {
17 const [packages, setPackages] = useState<PurchasesPackage[]>([]);
18 const [isLoading, setIsLoading] = useState(false);
19
20 useEffect(() => {
21 const fetchPackages = async () => {
22 try {
23 setIsLoading(true);
24 const offerings = await Purchases.getOfferings();
25 console.log("offerings", offerings);
26 setPackages(offerings.current?.availablePackages ?? []);
27 } catch (error) {
28 console.error("Error fetching packages:", error);
29 } finally {
30 setIsLoading(false);
31 }
32 };
33
34 fetchPackages();
35 }, []);
36
37 const purchasePackage = async (pkg: PurchasesPackage) => {
38 await Purchases.purchasePackage(pkg);
39 };
40
41 return { packages, isLoading, purchasePackage };
42};
43
44export const useCustomerInfo = () => {
45 const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null);
46 const [isLoading, setIsLoading] = useState(true);
47
48 useEffect(() => {
49 const fetchCustomerInfo = async () => {
50 try {
51 setIsLoading(true);
52 const info = await Purchases.getCustomerInfo();
53 setCustomerInfo(info);
54 } catch (error) {
55 console.error("Error fetching customer info:", error);
56 } finally {
57 setIsLoading(false);
58 }
59 };
60
61 fetchCustomerInfo();
62
63 // Set up listener for customer info updates
64 const customerInfoUpdated = (info: CustomerInfo) => {
65 setCustomerInfo(info);
66 };
67
68 Purchases.addCustomerInfoUpdateListener(customerInfoUpdated);
69
70 return () => {
71 Purchases.removeCustomerInfoUpdateListener(customerInfoUpdated);
72 };
73 }, []);
74
75 const hasActiveEntitlement = (entitlementId: string) => {
76 return !!customerInfo?.entitlements.active[entitlementId];
77 };
78
79 return {
80 customerInfo,
81 isLoading,
82 hasActiveEntitlement,
83 };
84};
We have three functions in this file which enable purchasing on the iOS and Android apps, later on we will be building the web version of these so that we can just call the same function in our non-platform specific code with React Native handling of calling the correct function based on the platform. Let’s look at this in more detail.
We have three custom hooks:
- initializePayments is for initializing the RevenueCat SDK when the app is mounted.
- usePackages, for both displaying and purchasing packages. We are going to call this hook in the screen which displays the packages available for purchasing. The hook also returns a function to purchase available packages.
- Last we have useCustomerInfo which allows us to check if user has the correct entitlements and can access content
In our App.tsx
file replace the contents with this to make use of those hooks:
1export default function HomeScreen() {
2 const { packages, isLoading, purchasePackage } = usePackages();
3 return (
4 <ScrollView style={styles.container}>
5 {packages?.map((p) => (
6 <PackageCard
7 key={p.identifier}
8 pkg={p}
9 purchasePackage={purchasePackage}
10 />
11 ))}
12 </ScrollView>
13 );
14}
15
You also need to create a new file PackageCard
and paste create a component that looks like this:
1import { StyleSheet, TouchableOpacity } from "react-native";
2import { PurchasesPackage } from "react-native-purchases";
3import { ThemedView } from "./ThemedView";
4import { ThemedText } from "./ThemedText";
5
6type Props = {
7 pkg: PurchasesPackage;
8 purchasePackage: (pkg: PurchasesPackage) => void;
9};
10
11export const PackageCard = ({ pkg, purchasePackage }: Props) => {
12 return (
13 <TouchableOpacity
14 key={pkg.identifier}
15 style={styles.card}
16 onPress={() => purchasePackage(pkg)}
17 >
18 <ThemedView style={styles.cardContent}>
19 <ThemedText style={styles.packageTitle}>{pkg.product.title}</ThemedText>
20 <ThemedText style={styles.packageType}>
21 {pkg.product.description}
22 </ThemedText>
23 <ThemedText style={styles.price}>{pkg.product.priceString}</ThemedText>
24 </ThemedView>
25 </TouchableOpacity>
26 );
27};
28
29const styles = StyleSheet.create({
30 container: {
31 flex: 1,
32 padding: 16,
33 },
34 titleContainer: {
35 flexDirection: "row",
36 alignItems: "center",
37 gap: 8,
38 },
39 stepContainer: {
40 gap: 8,
41 marginBottom: 8,
42 padding: 8,
43 },
44 reactLogo: {
45 height: 178,
46 width: 290,
47 bottom: 0,
48 left: 0,
49 position: "absolute",
50 },
51 headerText: {
52 fontSize: 24,
53 marginBottom: 16,
54 fontWeight: "600",
55 },
56 card: {
57 marginVertical: 8,
58 borderRadius: 12,
59 overflow: "hidden",
60 elevation: 2,
61 boxShadow: "0px 2px 4px rgba(0, 0, 0, 0.1)",
62 },
63 cardContent: {
64 padding: 16,
65 borderRadius: 12,
66 },
67 packageTitle: {
68 fontSize: 18,
69 fontWeight: "600",
70 marginBottom: 8,
71 },
72 packageType: {
73 fontSize: 14,
74 opacity: 0.7,
75 marginBottom: 8,
76 },
77 price: {
78 fontSize: 20,
79 fontWeight: "700",
80 },
81});
With this we should have a functioning code for Native purchases. You can go ahead and run the app on your simulator or emulator. The configured subscriptions should pop up and pressing on them should initiate a purchase process. To learn more about testing subscriptions, refer to Android testing documentation and iOS testing documentation.
To support subscriptions on iOS and Android, you’ve used react-native-purchases within a purchases.native.ts file. This includes initializing RevenueCat, fetching packages, handling purchases, and monitoring entitlements. These shared hooks keep native logic clean and consistent, ready to plug into your Expo app UI.
Step 3 – Add Web Billing your React Native project
Now that we have a working code for the iOS and Android version of the app it’s time to wire things up for the Web version so we can make web purchases too. RevenueCat is made for this case, allowing you to start selling subscriptions on the web and connect them to the same subscriptions and entitlements on mobile.
Since we want users to be able to subscribe to our web app, we are going to use the Web SDK to initialize the purchases. It works the same way as the react-native-purchases, providing the same APIs but the purchase process is only slightly different from how it happens on mobile.
Run the following command to install the package in your project:
npm install --save @revenuecat/purchases-js
Configure purchases.js
Now create a new file called purchases.web.ts and paste the following contents inside it:
1import { usePaymentsConfig } from "@/hooks/usePaymentsConfig";
2import {
3 CustomerInfo,
4 ErrorCode,
5 Package,
6 Purchases,
7} from "@revenuecat/purchases-js";
8import { useEffect, useState } from "react";
9
10export const initializePayments = async (apiKey: string) => {
11 const appUserId = Purchases.generateRevenueCatAnonymousAppUserId();
12 Purchases.configure(apiKey, appUserId);
13};
14
15export const offeringId = "default";
16
17export const webReset = () => {
18 // Remove all styles from the html and body tags after completing RevenueCat purchase
19 // this is needed as during the purchase process, the body tag is styled with styles which
20 // override the default styles of the Expo app
21 ["html", "body"].forEach((tag) =>
22 document.querySelector(tag)?.removeAttribute("style")
23 );
24};
25
26export const usePackages = () => {
27 const { isConfigured } = usePaymentsConfig();
28 const [packages, setPackages] = useState<Package[]>([]);
29 const [isLoading, setIsLoading] = useState(false);
30
31 useEffect(() => {
32 if (!isConfigured) return;
33
34 const fetchPackages = async () => {
35 const offerings = await Purchases.getSharedInstance().getOfferings();
36 setPackages(offerings.all[offeringId].availablePackages);
37 };
38
39 fetchPackages();
40 }, [isConfigured]);
41
42 const purchasePackage = async (pkg: Package) => {
43 try {
44 const { customerInfo } = await Purchases.getSharedInstance().purchase({
45 customerEmail: "perttu+3@lahteenlahti.com",
46 rcPackage: pkg,
47 });
48 return customerInfo;
49 } catch (error) {
50 console.log(error);
51 if (error === ErrorCode.UserCancelledError) {
52 return null;
53 }
54 } finally {
55 webReset();
56 }
57 };
58
59 return { packages, isLoading, purchasePackage };
60};
61
62export const useCustomerInfo = () => {
63 const isConfigured = usePaymentsConfig();
64 const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null);
65 const [isLoading, setIsLoading] = useState(true);
66
67 useEffect(() => {
68 if (!isConfigured) return;
69
70 const fetchCustomerInfo = async () => {
71 try {
72 setIsLoading(true);
73 const info = await Purchases.getSharedInstance().getCustomerInfo();
74 setCustomerInfo(info);
75 } catch (error) {
76 console.error("Error fetching customer info:", error);
77 } finally {
78 setIsLoading(false);
79 }
80 };
81
82 fetchCustomerInfo();
83 }, [isConfigured]);
84
85 const hasActiveEntitlement = (entitlementId: string) => {
86 return !!customerInfo?.entitlements.active[entitlementId];
87 };
88
89 return {
90 customerInfo,
91 isLoading,
92 hasActiveEntitlement,
93 };
94};
Let’s go through parts of the code to understand what is happening.
First we have the initializePayments, this should only be called once in your app, so it should be placed inside the useEffect that is called in the App.tsx file during mount. Looking more into internals of this function, you can see that it does two things, it first creates an anonymous userId and then initializes the SDK with that ID. If you want to create an app where the subscription you bought in iOS is automatically recognized and used for the user on the web app, you should return the id from your own authentication system so that subscriptions are attributed to the same user no matter the platform.
I’ve added an extra resetStyles function to call after the RevenueCat Web Billing powered purchase flow finishes.This is needed because at the moment some of the styles of Expo-router clash with the styles of RevenueCat Web Billing, causing things to look a bit weird:
1export const webReset = () => {
2 ["html", "body"].forEach((tag) =>
3 document.querySelector(tag)?.removeAttribute("style")
4 );
5};
Other parts of the code are quite similar to the iOS and Android code. Now if you run your app and open the web version your app should display available subscriptions and allow you to buy them.
Conclusion
With just a bit of configuration and some platform-aware code, you’ve now got a single React Native app that can sell subscriptions across iOS, Android, and Web using RevenueCat. You’ve set up products in App Store Connect, Google Play Console, and Stripe, and wired them up to a shared entitlement in RevenueCat. Thanks to platform-specific modules and a unified API surface, the app logic stays clean and consistent.
The next step would be to test your subscriptions. We just happen to have guides for all of these:
- for iOS testing follow this guide
- for Android testing follow this guide
- and for Web take a look at the Web billings documentation and it’s testing part
You might also like
- Blog post
Expo In-App Purchase Tutorial
The getting started guide for in-app purchases and subscriptions