作るもの

このコードラボでは、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 を通じてモバイルでもアンロックされる、同じエンタイトルメント。
なぜウェブで販売するのか? ウェブでの購入は App Store や Google Play の手数料の対象になりません。 さらに、アプリストアの外にいるユーザーにも届けられます。RevenueCat なら 1 つのエンタイトルメントがウェブと モバイルにまたがるため、2 つ目のサブスクリプションの仕組みを作る必要はありません。

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 にインポート
モバイルアプリと同じプロジェクトで。 Web Billing は、iOS と Android のアプリが使っている 同じ RevenueCat プロジェクトに追加してください。1 つのエンタイトルメントがすべてのプラットフォームに またがるのは、そのためです。

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_ で始まります。開発ではサンドボックスキーを使います。

何をどこで構成するか。 プロダクト、価格、通貨、トライアル、オファリングは RevenueCat 側にあります。 Stripe は決済(と、任意で Stripe Tax)だけを処理します。Web Billing の経路では、Stripe 側でプロダクトを作成しません。

Web SDK をインストールして構成する

Web SDK をインストールします。

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

Web SDK はクライアントサイドのみで動きます。Next.js の App Router では、 useEffect の中で SDK を構成する 'use client' プロバイダを使うということです。 公開鍵は NEXT_PUBLIC_ 環境変数から読み取ります(公開鍵なので、ブラウザに露出させても問題ありません)。 秘密の sk_ キーはクライアントコードに絶対に置かないでください。

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 が必須です。 モバイル SDK とは違い、ウェブの configure には appUserId が必要です。暗黙の匿名モードはありません(Purchases.generateRevenueCatAnonymousAppUserId() で 1 つ生成できますが、クロスプラットフォームには実際の共有 ID が必要で、それが次のステップです)。
構成は 1 度だけ、切り替えは安全に。 configure は 2 回呼ぶとエラーになるため、 isConfigured() によるガードが重要です(React StrictMode は開発時にエフェクトを 2 回実行します)。 クリーンアップの active フラグが前の appUserId の結果を破棄するので、素早いユーザー切り替えでも 古いエンタイトルメント状態が残ることはありません。

ユーザーを識別する

ウェブでの購入をモバイルで機能させるのが、このステップです。RevenueCat は、同じ App User ID で サインインしたユーザーを、プラットフォームをまたいで同一の顧客として扱います。そこで、自分の安定したユーザー ID (Firebase や Auth0 などの認証・ID プロバイダから取得したもの)をプロバイダに渡し、モバイルアプリでも 同じ ID を使ってください。

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>
  );
}
安定した、個人情報でない ID を使う。 メールアドレスではなく、バックエンドのユーザー ID を選んでください (メールアドレスは変わります)。匿名 ID はプラットフォーム間で引き継がれません(ウェブの匿名 ID には $RCAnonymousID: という接頭辞が付きます)。そのため、クロスプラットフォームの動作をアンロックするのは、 共有された識別済み ID です。

プロダクトと価格を表示する

現在のオファリングを取得し、各パッケージのローカライズされた価格を読み取ります。ウェブではプロダクトが pkg.webBillingProduct にあり、表示価格は webBillingProduct.price.formattedPrice です (通貨込みでフォーマット済みなので、ハードコードしないでください)。

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>
//   ));

購入する

purchase({ rcPackage }) を呼び出します。RevenueCat がホスト型チェックアウト(デフォルトはモーダル、 または渡した要素にマウント)を表示し、Stripe 経由で決済を集め、更新後の CustomerInfo を返します。 ユーザーがチェックアウトを閉じたケース(UserCancelledError)は、本物のエラーとは分けて処理してください。

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>
  );
}
ウェブには CustomerInfo のリスナーがありません。 モバイル SDK とは違い、Web SDK には addCustomerInfoUpdateListener がありません。purchase() が返す customerInfo を 使うか、getCustomerInfo() で再取得してください(ここでの refresh() がそれにあたります)。

エンタイトルメントを確認してコンテンツをゲートする

ウェブでは entitlements.active がエンタイトルメント ID をキーとするプレーンなオブジェクトなので、 ドキュメントで案内されているチェックは in 演算子を使います。サーバーとクライアントのハイドレーションの 不一致を避けるため(サーバーレンダリングにはエンタイトルメントがありません)、プレミアム UI はエンタイトルメントと 「読み込み完了」フラグの両方でゲートしてください。

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>;
}

プロバイダ内の isProENTITLEMENT_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 では 次のようになります。

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.

これがウェブで RevenueCat を使う真価です。2 つ目のエンタイトルメントの仕組みを作らずに済んでいます。ウェブでの購入、 モバイルでの購入、更新、キャンセルのすべてが、1 人の顧客と 1 つの pro エンタイトルメントに集約されます。

リファレンスアプリ。 RevenueCat の expo-web-billing-demo は、この「どのプラットフォームで契約しても、どこでもアンロックされる」フローを iOS、Android、ウェブにまたがって示しています。

先に React Native 側が必要ですか? その場合は React Native コードラボユーザー識別ガイドを参照してください。

サンドボックスでテストしてまとめる

実際のお金を使わずに購入をテストする

  1. 開発では サンドボックスキー(rcb_sb_...)で SDK を構成します。自動的に Stripe のテストモードを使います。
  2. アプリを実行してサインインし(安定した App User ID を持つため)、ペイウォールを開きます。
  3. subscribe をクリックし、Stripe のテストカードでホスト型チェックアウトを完了します(Stripe の標準テスト Visa は 4242 4242 4242 4242、有効期限は任意の未来の日付、CVC は任意)。
  4. UI が「You have Pro access」に切り替わることを確認し、同じユーザーがモバイルアプリで pro を表示するか確認します。
サンドボックスの注意点。 サンドボックスのサブスクリプションは実際のものより速く更新され(自動キャンセル までに最大 6 回)、更新のテストに便利です。rcb_sb_ キーは本番に絶対に出さないでください。また、 サンドボックスのチェックアウト URL は実際のエンタイトルメントをアンロックしうるため、共有しないでください。

作ったもの

Stripe を RevenueCat Web Billing に接続し、pro エンタイトルメントを構成し、 @revenuecat/purchases-js を使って Next.js からサブスクリプションを販売し、エンタイトルメントで コンテンツをゲートし、共有された App User ID を通じてモバイルでも同じエンタイトルメントが有効になるようにしました。

次に進む