What You'll Build

In this codelab you will sell a subscription on the web with RevenueCat Web Billing (which uses Stripe as its payment gateway) from a Next.js app, using the @revenuecat/purchases-js SDK. The payoff: because RevenueCat is the source of truth for subscription status, the purchase made on the web unlocks the pro entitlement, and the same entitlement is active in your mobile app too, as long as both use the same App User ID.

By the end you will have:

  • A connected Stripe account and a pro entitlement, product, and offering configured in RevenueCat.
  • A client-side RevenueCat provider in Next.js (App Router) configured with your Web Billing public key.
  • A pricing page that lists packages and starts the RevenueCat hosted checkout.
  • Premium content gated on the pro entitlement.
  • The same entitlement unlocking on mobile through a shared App User ID.
Why sell on the web? Web purchases are not subject to the App Store or Google Play commission, and they let you reach users outside the app stores. With RevenueCat, one entitlement spans web and mobile, so you do not have to build a second subscription system.

How Web Billing and Stripe Fit Together

RevenueCat Web Billing is RevenueCat's own billing engine, with Stripe as the payment gateway underneath. You configure your products, prices, offerings, and entitlements inside RevenueCat, and RevenueCat renders the checkout UI and processes the card through Stripe. RevenueCat never stores card data; Stripe handles payment.

There are two Stripe-based paths in RevenueCat. This codelab uses the first:

Path Where products live
Web Billing (this codelab) Configured in RevenueCat
Stripe Billing integration Created in Stripe, imported into RevenueCat
Same project as your mobile app. Add Web Billing to the same RevenueCat project that your iOS and Android apps use. That is what lets one entitlement span every platform.

Connect Stripe and Configure Web Billing

All of this happens in the RevenueCat dashboard, no code yet.

1. Connect your Stripe account

In your RevenueCat account settings, click Connect Stripe account and install the RevenueCat app in Stripe (an OAuth flow, not manual API keys). Only the project owner can connect Stripe.

2. Create a Web Billing app

In your project, add a new app and choose Web Billing, selecting your connected Stripe account as the payment gateway.

3. Configure product, offering, and entitlement

  • Create an entitlement with the identifier pro.
  • Create a product (for example a monthly subscription) and its price, then attach it to pro.
  • Add the product to a package in your default offering (available later as offerings.current).

4. Grab your public API keys

From the Web Billing app settings, copy the public API keys. There are two: production starts with rcb_, and sandbox starts with rcb_sb_. You will use the sandbox key for development.

What is configured where. Products, prices, currencies, trials, and offerings live in RevenueCat. Stripe only processes payments (and optionally Stripe Tax). You do not create products in Stripe for the Web Billing path.

Install and Configure the Web SDK

Install the Web SDK:

bash
npm install --save @revenuecat/purchases-js

The Web SDK runs client-side only. In the Next.js App Router, that means a 'use client' provider that configures the SDK in a useEffect. Read the public key from a NEXT_PUBLIC_ environment variable (it is a public key, so exposing it to the browser is expected and safe). Never put a secret sk_ key in client code.

tsx
// app/providers/RevenueCatProvider.tsx
'use client';

import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { Purchases, LogLevel } from '@revenuecat/purchases-js';
import type { CustomerInfo } from '@revenuecat/purchases-js';

const API_KEY = process.env.NEXT_PUBLIC_RC_WEB_BILLING_KEY!; // rcb_sb_... in dev
export const ENTITLEMENT_ID = 'pro';

type RCValue = {
  customerInfo: CustomerInfo | null;
  isPro: boolean;
  isReady: boolean;
  refresh: () => Promise<void>;
};

const RevenueCatContext = createContext<RCValue | null>(null);

export function RevenueCatProvider({
  appUserId,
  children,
}: {
  appUserId: string;
  children: ReactNode;
}) {
  const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null);
  const [isReady, setIsReady] = useState(false);

  // Re-fetch after a purchase (the Web SDK has no update listener).
  const refresh = async () => {
    try {
      setCustomerInfo(await Purchases.getSharedInstance().getCustomerInfo());
    } catch (e) {
      console.warn('getCustomerInfo failed', e);
    }
  };

  useEffect(() => {
    let active = true; // ignore results from a previous appUserId / unmount
    setIsReady(false);

    (async () => {
      Purchases.setLogLevel(LogLevel.Verbose);

      // Configure once. On later sign-ins, switch users. Both give us CustomerInfo.
      let info: CustomerInfo;
      if (!Purchases.isConfigured()) {
        Purchases.configure({ apiKey: API_KEY, appUserId });
        info = await Purchases.getSharedInstance().getCustomerInfo();
      } else {
        info = await Purchases.getSharedInstance().changeUser(appUserId);
      }

      if (active) setCustomerInfo(info);
    })()
      .catch((e) => console.warn('RevenueCat setup failed', e))
      .finally(() => { if (active) setIsReady(true); });

    return () => { active = false; };
  }, [appUserId]);

  const isPro = !!customerInfo && ENTITLEMENT_ID in customerInfo.entitlements.active;

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

export function useRevenueCat() {
  const ctx = useContext(RevenueCatContext);
  if (!ctx) throw new Error('useRevenueCat must be used inside RevenueCatProvider');
  return ctx;
}
appUserId is required on web. Unlike the mobile SDKs, configure on web requires an appUserId. There is no implicit anonymous mode (you can generate one with Purchases.generateRevenueCatAnonymousAppUserId(), but for cross-platform you want a real shared id, which is the next step).
Configure once, switch safely. configure throws if called twice, so the isConfigured() guard matters (React StrictMode runs effects twice in development). The cleanup's active flag discards results from a previous appUserId, so a fast user switch cannot leave stale entitlement state.

Identify the User

This is the step that makes a web purchase work on mobile. RevenueCat treats anyone signed in with the same App User ID as the same customer across platforms. So pass your own stable user id (from your auth or identity provider, such as Firebase or Auth0) to the provider, and use the same id in your mobile app.

tsx
// app/layout.tsx
import { RevenueCatProvider } from './providers/RevenueCatProvider';
import { getCurrentUserId } from '../lib/auth'; // your auth/session

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  // The SAME id you pass to Purchases.logIn(...) in your mobile app.
  const appUserId = await getCurrentUserId();

  return (
    <html lang="en">
      <body>
        <RevenueCatProvider appUserId={appUserId}>{children}</RevenueCatProvider>
      </body>
    </html>
  );
}
Use a stable, non-personal id. Pick your backend user id, not an email (emails change). Anonymous ids do not transfer between platforms (web anonymous ids are prefixed $RCAnonymousID:), so a shared identified id is what unlocks the cross-platform behavior.

Show Products and Prices

Fetch the current offering and read each package's localized price. On web the product is on pkg.webBillingProduct, and the display price is webBillingProduct.price.formattedPrice (already formatted with currency, so never hardcode it).

tsx
// app/hooks/usePackages.ts
'use client';

import { useEffect, useState } from 'react';
import { Purchases } from '@revenuecat/purchases-js';
import type { Package } from '@revenuecat/purchases-js';

export function usePackages() {
  const [packages, setPackages] = useState<Package[]>([]);

  useEffect(() => {
    Purchases.getSharedInstance()
      .getOfferings()
      .then((offerings) => {
        if (offerings.current) {
          setPackages(offerings.current.availablePackages);
        }
      })
      .catch((e) => console.warn('getOfferings failed', e));
  }, []);

  return packages;
}

// In a component:
//   const packages = usePackages();
//   packages.map((pkg) => (
//     <li key={pkg.identifier}>
//       {pkg.webBillingProduct.title}: {pkg.webBillingProduct.price.formattedPrice}
//     </li>
//   ));

Make a Purchase

Call purchase({ rcPackage }). RevenueCat presents its hosted checkout (a modal by default, or mounted into an element you pass), collects payment through Stripe, and resolves with the updated CustomerInfo. Handle the user closing the checkout (UserCancelledError) separately from real errors.

tsx
// app/components/Paywall.tsx
'use client';

import { Purchases, PurchasesError, ErrorCode } from '@revenuecat/purchases-js';
import type { Package } from '@revenuecat/purchases-js';
import { useRevenueCat, ENTITLEMENT_ID } from '../providers/RevenueCatProvider';
import { usePackages } from '../hooks/usePackages';

export function Paywall() {
  const { isPro, refresh } = useRevenueCat();
  const packages = usePackages();

  const buy = async (pkg: Package) => {
    try {
      const { customerInfo } = await Purchases.getSharedInstance().purchase({ rcPackage: pkg });
      if (ENTITLEMENT_ID in customerInfo.entitlements.active) {
        await refresh(); // sync the provider so the UI updates
      }
    } catch (e) {
      if (e instanceof PurchasesError && e.errorCode === ErrorCode.UserCancelledError) {
        return; // the user closed the checkout, not an error to surface
      }
      console.error('Purchase failed', e);
    }
  };

  if (isPro) return <p>You have Pro access. Thanks!</p>;

  return (
    <ul>
      {packages.map((pkg) => (
        <li key={pkg.identifier}>
          <button onClick={() => buy(pkg)}>
            Subscribe for {pkg.webBillingProduct.price.formattedPrice}
          </button>
        </li>
      ))}
    </ul>
  );
}
No customer-info listener on web. Unlike the mobile SDKs, the Web SDK has no addCustomerInfoUpdateListener. Use the customerInfo returned by purchase(), or re-fetch with getCustomerInfo() (that is what refresh() does here).

Check Entitlements and Gate Content

On web, entitlements.active is a plain object keyed by entitlement id, so the documented check uses the in operator. Gate your premium UI behind both the entitlement and a "loaded" flag to avoid a server-versus-client hydration mismatch (the server render has no entitlement).

tsx
// app/components/PremiumDashboard.tsx
'use client';

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

export function PremiumDashboard() {
  const { isPro, isReady } = useRevenueCat();

  if (!isReady) return <p>Loading...</p>;       // avoid hydration mismatch
  if (!isPro) return <Paywall />;                // not subscribed: show the paywall

  return <h1>Welcome to the premium dashboard</h1>;
}

The isPro value in the provider is computed with ENTITLEMENT_ID in customerInfo.entitlements.active. You can also call await Purchases.getSharedInstance().isEntitledTo('pro') for a one-off boolean check.

Web vs mobile idiom. Web uses 'pro' in customerInfo.entitlements.active. The mobile SDKs use typeof customerInfo.entitlements.active['pro'] !== 'undefined'. Both ask the same question; just keep them straight per platform.

Unlock the Same Entitlement on Mobile

Here is the payoff. Your mobile app does not need to know the purchase happened on the web. Because it signs in with the same App User ID, RevenueCat already reports the pro entitlement as active. In React Native:

tsx
// Mobile app (react-native-purchases), same RevenueCat project
import Purchases from 'react-native-purchases';

// Sign in with the SAME id used on the web.
await Purchases.logIn(appUserId);

const info = await Purchases.getCustomerInfo();
const isPro = typeof info.entitlements.active['pro'] !== 'undefined';
// isPro is true here if the user subscribed on the web. No restore needed.

That is the whole point of using RevenueCat for web: you did not build a second entitlement system. The web purchase, the mobile purchase, renewals, and cancellations all roll up to one customer and one pro entitlement.

Reference app. RevenueCat's expo-web-billing-demo shows this exact "subscribe on any platform, unlock everywhere" flow across iOS, Android, and web.

Need the React Native side first? See the React Native codelab and the Identify users guide.

Test in Sandbox and Recap

Test the purchase without real money

  1. Configure the SDK with your sandbox key (rcb_sb_...) in development. It automatically uses Stripe Test Mode.
  2. Run the app, sign in (so you have a stable App User ID), and open the paywall.
  3. Click subscribe and complete the hosted checkout with a Stripe test card (Stripe's standard test Visa is 4242 4242 4242 4242, any future expiry and any CVC).
  4. Confirm the UI flips to "You have Pro access," then check the same user shows pro in your mobile app.
Sandbox notes. Sandbox subscriptions renew faster than real ones (up to six times before they auto-cancel), which is handy for testing renewals. Never ship the rcb_sb_ key in production, and do not share sandbox checkout URLs, since they can unlock real entitlements.

What you built

You connected Stripe to RevenueCat Web Billing, configured a pro entitlement, sold a subscription from Next.js with @revenuecat/purchases-js, gated content on the entitlement, and made that same entitlement light up on mobile through a shared App User ID.

Keep going