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
proentitlement, 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
proentitlement. - The same entitlement unlocking on mobile through a shared App User ID.
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 |
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.
Install and Configure the Web SDK
Install the Web SDK:
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.
// 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;
}
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 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.
// 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>
);
}
$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).
// 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.
// 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>
);
}
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).
// 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.
'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:
// 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.
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
- Configure the SDK with your sandbox key (
rcb_sb_...) in development. It automatically uses Stripe Test Mode. - Run the app, sign in (so you have a stable App User ID), and open the paywall.
- 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). - Confirm the UI flips to "You have Pro access," then check the same user shows
proin your mobile app.
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
- Web SDK quickstart: a shorter reference for the purchases-js basics.
- Get CustomerInfo and Identify users: the building blocks used here.
- RevenueCat: Web Billing overview and Web SDK reference.
- RevenueCat: Connect your Stripe account.