作るもの
このコードラボでは、Next.js アプリから
@revenuecat/purchases-js SDK を使い、RevenueCat Web Billing(決済ゲートウェイに
Stripe を利用)でウェブ上でサブスクリプションを販売します。ポイントは、RevenueCat が
サブスクリプション状態の信頼できる唯一の情報源になる点です。ウェブでの購入が pro エンタイトルメントを
アンロックし、両方が同じ App User ID を使っていれば、モバイルアプリでも同じエンタイトルメントが有効になります。
完成時には、次のものが手に入ります。
- 接続済みの Stripe アカウントと、RevenueCat で構成した
proエンタイトルメント、プロダクト、オファリング。 - Web Billing の公開鍵で構成された、Next.js(App Router)のクライアントサイド RevenueCat プロバイダ。
- パッケージを一覧表示し、RevenueCat のホスト型チェックアウトを開始する価格ページ。
proエンタイトルメントでゲートされたプレミアムコンテンツ。- 共有された App User ID を通じてモバイルでもアンロックされる、同じエンタイトルメント。
Web Billing と Stripe の関係
RevenueCat Web Billing は RevenueCat 自身の課金エンジンであり、その下で Stripe が決済ゲートウェイとして 動きます。 プロダクト、価格、オファリング、エンタイトルメントは RevenueCat の内部で構成し、 RevenueCat がチェックアウト UI を描画して Stripe 経由でカードを処理します。RevenueCat がカードデータを保存する ことは決してなく、決済は Stripe が担います。
RevenueCat には Stripe を使う 2 つの経路があります。このコードラボでは 1 つ目を使います。
| 経路 | プロダクトの置き場所 |
|---|---|
| Web Billing(このコードラボ) | RevenueCat で構成 |
| Stripe Billing 連携 | Stripe で作成し、RevenueCat にインポート |
Stripe を接続して Web Billing を構成する
ここまではすべて RevenueCat ダッシュボード の作業で、まだコードは書きません。
1. Stripe アカウントを接続する
RevenueCat アカウント設定で Connect Stripe account をクリックし、Stripe に RevenueCat アプリを インストールします(手動の API キーではなく OAuth フローです)。Stripe を接続できるのはプロジェクトのオーナーだけです。
2. Web Billing アプリを作成する
プロジェクトで新しいアプリを追加して Web Billing を選び、決済ゲートウェイとして接続済みの Stripe アカウントを指定します。
3. プロダクト、オファリング、エンタイトルメントを構成する
- 識別子
proの エンタイトルメントを作成します。 - プロダクト(たとえば月額サブスクリプション)とその価格を作成し、
proに紐付けます。 - プロダクトを、デフォルトの オファリング(あとで
offerings.currentとして利用可能)の パッケージに追加します。
4. 公開 API キーを取得する
Web Billing アプリの設定から、公開 API キーをコピーします。キーは 2 つあり、本番用は rcb_ で始まり、
サンドボックス用は rcb_sb_ で始まります。開発ではサンドボックスキーを使います。
Web SDK をインストールして構成する
Web SDK をインストールします。
npm install --save @revenuecat/purchases-js
Web SDK はクライアントサイドのみで動きます。Next.js の App Router では、
useEffect の中で SDK を構成する 'use client' プロバイダを使うということです。
公開鍵は NEXT_PUBLIC_ 環境変数から読み取ります(公開鍵なので、ブラウザに露出させても問題ありません)。
秘密の sk_ キーはクライアントコードに絶対に置かないでください。
// 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 には
appUserId が必要です。暗黙の匿名モードはありません(Purchases.generateRevenueCatAnonymousAppUserId()
で 1 つ生成できますが、クロスプラットフォームには実際の共有 ID が必要で、それが次のステップです)。
configure は 2 回呼ぶとエラーになるため、
isConfigured() によるガードが重要です(React StrictMode は開発時にエフェクトを 2 回実行します)。
クリーンアップの active フラグが前の appUserId の結果を破棄するので、素早いユーザー切り替えでも
古いエンタイトルメント状態が残ることはありません。
ユーザーを識別する
ウェブでの購入をモバイルで機能させるのが、このステップです。RevenueCat は、同じ App User ID で サインインしたユーザーを、プラットフォームをまたいで同一の顧客として扱います。そこで、自分の安定したユーザー ID (Firebase や Auth0 などの認証・ID プロバイダから取得したもの)をプロバイダに渡し、モバイルアプリでも 同じ ID を使ってください。
// 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: という接頭辞が付きます)。そのため、クロスプラットフォームの動作をアンロックするのは、
共有された識別済み ID です。
プロダクトと価格を表示する
現在のオファリングを取得し、各パッケージのローカライズされた価格を読み取ります。ウェブではプロダクトが
pkg.webBillingProduct にあり、表示価格は webBillingProduct.price.formattedPrice です
(通貨込みでフォーマット済みなので、ハードコードしないでください)。
// 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>
// ));
購入する
purchase({ rcPackage }) を呼び出します。RevenueCat がホスト型チェックアウト(デフォルトはモーダル、
または渡した要素にマウント)を表示し、Stripe 経由で決済を集め、更新後の CustomerInfo を返します。
ユーザーがチェックアウトを閉じたケース(UserCancelledError)は、本物のエラーとは分けて処理してください。
// 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 がありません。purchase() が返す customerInfo を
使うか、getCustomerInfo() で再取得してください(ここでの refresh() がそれにあたります)。
エンタイトルメントを確認してコンテンツをゲートする
ウェブでは entitlements.active がエンタイトルメント ID をキーとするプレーンなオブジェクトなので、
ドキュメントで案内されているチェックは in 演算子を使います。サーバーとクライアントのハイドレーションの
不一致を避けるため(サーバーレンダリングにはエンタイトルメントがありません)、プレミアム UI はエンタイトルメントと
「読み込み完了」フラグの両方でゲートしてください。
// 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>;
}
プロバイダ内の isPro は ENTITLEMENT_ID in customerInfo.entitlements.active で計算されます。
単発の真偽値チェックには await Purchases.getSharedInstance().isEntitledTo('pro') を呼ぶこともできます。
'pro' in customerInfo.entitlements.active を
使います。モバイル SDK では typeof customerInfo.entitlements.active['pro'] !== 'undefined' を使います。
どちらも確認している内容は同じですが、プラットフォームごとに使い分けてください。
モバイルで同じエンタイトルメントをアンロックする
ここが本題です。モバイルアプリは、購入がウェブで起きたことを知る必要がありません。同じ App User ID
でサインインするため、RevenueCat はすでに pro エンタイトルメントを有効として報告します。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.
これがウェブで RevenueCat を使う真価です。2 つ目のエンタイトルメントの仕組みを作らずに済んでいます。ウェブでの購入、
モバイルでの購入、更新、キャンセルのすべてが、1 人の顧客と 1 つの pro エンタイトルメントに集約されます。
先に React Native 側が必要ですか? その場合は React Native コードラボと ユーザー識別ガイドを参照してください。
サンドボックスでテストしてまとめる
実際のお金を使わずに購入をテストする
- 開発では サンドボックスキー(
rcb_sb_...)で SDK を構成します。自動的に Stripe のテストモードを使います。 - アプリを実行してサインインし(安定した App User ID を持つため)、ペイウォールを開きます。
- subscribe をクリックし、Stripe のテストカードでホスト型チェックアウトを完了します(Stripe の標準テスト Visa は
4242 4242 4242 4242、有効期限は任意の未来の日付、CVC は任意)。 - UI が「You have Pro access」に切り替わることを確認し、同じユーザーがモバイルアプリで
proを表示するか確認します。
rcb_sb_ キーは本番に絶対に出さないでください。また、
サンドボックスのチェックアウト URL は実際のエンタイトルメントをアンロックしうるため、共有しないでください。
作ったもの
Stripe を RevenueCat Web Billing に接続し、pro エンタイトルメントを構成し、
@revenuecat/purchases-js を使って Next.js からサブスクリプションを販売し、エンタイトルメントで
コンテンツをゲートし、共有された App User ID を通じてモバイルでも同じエンタイトルメントが有効になるようにしました。
次に進む
- Web SDK クイックスタート: purchases-js の基本を、より短くまとめたリファレンス。
- CustomerInfo の取得とユーザーの識別: ここで使った構成要素。
- RevenueCat: Web Billing の概要とWeb SDK リファレンス。
- RevenueCat: Stripe アカウントを接続する。