作るもの

このコードラボでは、Expo Router アプリのプレミアム画面を RevenueCat のサブスクリプションでロックします。無料ユーザーは公開画面を閲覧できますが、 プレミアムルートは pro エンタイトルメントを持つまでナビゲーションツリーそのものに存在しません。 購入するとルートが現れ、手動で更新しなくてもアプリがそこへナビゲートします。

完成すると、次のものが手に入ります。

  • 無料の Home と、サブスクリプション専用の Analytics 画面を持つタブアプリ。
  • RevenueCat のエンタイトルメントで駆動する、Expo Router の <Stack.Protected> によるルートゲート。
  • ロックされた画面が要求されたときに react-native-purchases-ui で表示するペイウォール。
  • 自動アンロック。購入が成功すると、CustomerInfo リスナーを通じてガードが切り替わります。
  • 最初のエンタイトルメントチェックが完了する前の「リダイレクトのちらつき」を防ぐスプラッシュゲート。
このパターンが重要な理由。 認証ロールではなく RevenueCat のエンタイトルメントでゲートすると、 RevenueCat がサブスクリプション状態の信頼できる唯一の情報源となるため、アクセス権が端末や再インストールをまたいで 自動的にサブスクリプションに追従します。
RevenueCat SDK が初めてですか? まず SDK を設定するCustomerInfo を取得するのガイドにざっと目を通してから、ここへ戻ってきてください。

前提条件とプロジェクトのセットアップ

用意するものは二つです。Expo プロジェクトと RevenueCat プロジェクトです。

1. SDK 53 以降の Expo アプリ

<Stack.Protected>(Guarded Groups とも呼ばれます)は Expo SDK 53 / Expo Router v5 で導入されました。 新しいアプリを作成します。

bash
npx create-expo-app@latest premium-routes
cd premium-routes

2. development build(Expo Go では購入が動きません)

RevenueCat はネイティブモジュールを必要とするため、実際の購入は Expo Go では動きません。 development build を使う必要があります。 dev client を追加して、ビルドを作成します。

bash
npx expo install expo-dev-client
eas build --profile development --platform ios
# or: eas build --profile development --platform android
Expo Go についての注意。 react-native-purchases には Preview API Mode が含まれるため、 Expo Go でもアプリは起動します。ただし実際の購入が動くのは development build だけです。購入は実機でテストしてください。

3. エンタイトルメント、オファリング、ペイウォールを備えた RevenueCat プロジェクト

RevenueCat ダッシュボードで、次を設定します。

  • 識別子 proエンタイトルメント(Product Catalog → Entitlements)。
  • そのエンタイトルメントに紐付けたプロダクトを少なくとも一つ(App Store Connect / Google Play で構成)。
  • オファリングofferings.current で取得できるデフォルトのオファリング)。
  • そのオファリングに紐付けたペイウォール。デザイン済みのペイウォールが表示されます(未設定の場合、RevenueCatUI はデフォルトのペイウォールを表示します)。

Google Play 側を設定しますか? Google Play サービスアカウントのセットアップガイドを参照してください。

RevenueCat のインストールと設定

Expo SDK にバージョンを合わせるため、どちらのパッケージも expo install でインストールします。まとめてインストールしてください。react-native-purchases-ui が対応する react-native-purchases のバージョンを固定します。

bash
npx expo install react-native-purchases react-native-purchases-ui
config plugin は不要です。 これらのパッケージは Expo の config plugin を同梱していないため、 app.jsonplugins 配列には追加しないでください。ネイティブモジュールをインストールしたら dev build を再ビルドします。

SDK の設定は一度だけ、できるだけ早い段階で行います。Expo Router アプリでは、ルートレイアウトの app/_layout.tsx がそれにあたります。プラットフォームごとに公開 API キーを使い(iOS のキーは appl_、Google Play のキーは goog_ で始まります)、設定の前にログレベルを指定します。

tsx
// app/_layout.tsx
import { useEffect } from 'react';
import { Platform } from 'react-native';
import { Stack } from 'expo-router';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';

const API_KEYS = {
  apple: 'appl_xxxxxxxxxxxxxxxxxxxxxxxx',
  google: 'goog_xxxxxxxxxxxxxxxxxxxxxxxx',
};

export default function RootLayout() {
  useEffect(() => {
    Purchases.setLogLevel(LOG_LEVEL.VERBOSE); // call before configure

    if (Platform.OS === 'ios') {
      Purchases.configure({ apiKey: API_KEYS.apple });
    } else if (Platform.OS === 'android') {
      Purchases.configure({ apiKey: API_KEYS.google });
    }
  }, []);

  return <Stack />;
}
キーはソース管理の外に置きましょう。 実際のアプリでは、キーをハードコードせず環境変数から読み込んでください (たとえば process.env.EXPO_PUBLIC_RC_IOS_KEY)。
注意。 この useEffect の配置は、レイアウトが <Stack /> だけを レンダーしているうちは問題ありません。起動時に画面がエンタイトルメントを読み取るようになったら(ステップ 4 以降)、 その最初の読み取りより前に走るよう configure をモジュールスコープに移します。最終版はステップ 9 で示し、 その理由も説明します。

フックで Pro アクセスを追跡する

ルートガードに必要なのは一つの真偽値です。現在のユーザーは pro エンタイトルメントを持っているか、という値です。 これを一度読み取り、その後は変更を監視して同期を保つ小さなフックを作ります。

tsx
// hooks/useProAccess.ts
import { useEffect, useState } from 'react';
import Purchases, { CustomerInfo } from 'react-native-purchases';

export const ENTITLEMENT_ID = 'pro';

export function useProAccess() {
  const [isPro, setIsPro] = useState(false);
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    let active = true;

    // A single function reference, reused for the initial read,
    // the listener, and the cleanup.
    const update = (info: CustomerInfo) => {
      if (active) {
        setIsPro(typeof info.entitlements.active[ENTITLEMENT_ID] !== 'undefined');
      }
    };

    // 1) Read the current state once.
    Purchases.getCustomerInfo()
      .then((info) => { if (active) update(info); })
      .catch((e) => console.warn('getCustomerInfo failed', e))
      .finally(() => { if (active) setIsReady(true); });

    // 2) Subscribe to future changes (purchases, renewals, restores).
    Purchases.addCustomerInfoUpdateListener(update);

    // 3) Clean up: stop pending state updates and remove the SAME reference.
    return () => {
      active = false;
      Purchases.removeCustomerInfoUpdateListener(update);
    };
  }, []);

  return { isPro, isReady };
}
重要: リスナーは void を返します。 addCustomerInfoUpdateListener はサブスクリプションオブジェクトを返しません。そのため const sub = addCustomerInfoUpdateListener(...); sub.remove() は誤りで、例外を投げます。 同じ関数の参照を保持し、それを removeCustomerInfoUpdateListener に渡してください。 CustomerInfo リスナーガイドを参照してください。

isReady は、最初の getCustomerInfo() が完了するまで false です。 ステップ 9 で、ユーザーのエンタイトルメントを実際に把握する前にリダイレクトしてしまわないよう、この値を使います。

ルートを構成する

Expo Router はファイルベースで、app/ 以下の各ファイルがそのままルートに対応します。常に公開する画面を (tabs) グループに、サブスクリプション専用の画面を別の (premium) グループに置き、 まとめてガードできるようにします。

text
app/
  _layout.tsx          # root Stack: RevenueCat config + the route guard
  (tabs)/
    _layout.tsx        # Tabs: Home + Settings (always visible)
    index.tsx          # Home (free) with an "Unlock Premium" button
    settings.tsx       # Settings (free) with a "Restore Purchases" button
  (premium)/
    _layout.tsx        # Stack for premium-only screens
    analytics.tsx      # premium screen, gated by the "pro" entitlement
hooks/
  useProAccess.ts      # from Step 4

プレミアムグループのレイアウトは、ただの通常のスタックです。ゲートは一つ上、ルートレイアウトで行います。

tsx
// app/(premium)/_layout.tsx
import { Stack } from 'expo-router';

export default function PremiumLayout() {
  return <Stack />;
}

タブのレイアウトでは、常に公開する二つの画面を宣言します。

tsx
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';

export default function TabsLayout() {
  return (
    <Tabs>
      <Tabs.Screen name="index" options={{ title: 'Home' }} />
      <Tabs.Screen name="settings" options={{ title: 'Settings' }} />
    </Tabs>
  );
}

Stack.Protected でルートをゲートする

いよいよ核心です。(premium) グループを <Stack.Protected> でラップし、 エンタイトルメントの真偽値を guard として渡します。guardfalse のとき、 それらのルートはナビゲーションツリーから取り除かれ、開こうとする操作(ディープリンクを含む)は、最初に利用できる 保護されていない画面へリダイレクトされます。

tsx
// app/_layout.tsx (guard added)
import { Stack } from 'expo-router';
import { useProAccess } from '../hooks/useProAccess';

export default function RootLayout() {
  const { isPro } = useProAccess();

  return (
    <Stack>
      {/* Always available */}
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />

      {/* Only mounted while the user holds the "pro" entitlement */}
      <Stack.Protected guard={isPro}>
        <Stack.Screen name="(premium)" options={{ headerShown: false }} />
      </Stack.Protected>
    </Stack>
  );
}
リダイレクト先は、最初の保護されていない画面です。 (tabs)Stack.Protected でラップされていないため、/(premium)/analytics へのディープリンクを タップした無料ユーザーは、タブへ戻されます。専用のペイウォールルートに着地させたい場合は、そのルートを保護せずに残し、 (unstable_settings で)アンカーに指定してください。
あくまでクライアント側の仕組みです。 保護ルートはナビゲーションを便利にするものであり、セキュリティ境界ではありません。 重要な処理では、必ずサーバー側や RevenueCat のエンタイトルメントでプレミアムアクセスを検証してください。

ロック中のユーザーをペイウォールへ誘導する

無料ユーザーにはナビゲートできるプレミアムルートがないので、明確な入り口を用意します。ペイウォールを表示する 「Unlock Premium」ボタンです。react-native-purchases-ui は、ユーザーがエンタイトルメントを 持っていないときだけペイウォールを表示できます。

tsx
// app/(tabs)/index.tsx
import { useEffect, useState } from 'react';
import { View, Text, Button } from 'react-native';
import { useRouter } from 'expo-router';
import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui';
import { useProAccess, ENTITLEMENT_ID } from '../../hooks/useProAccess';

export default function Home() {
  const router = useRouter();
  const { isPro } = useProAccess();
  const [pendingUnlock, setPendingUnlock] = useState(false);

  const unlockPremium = async () => {
    // Shows the paywall only if the entitlement is missing.
    const result = await RevenueCatUI.presentPaywallIfNeeded({
      requiredEntitlementIdentifier: ENTITLEMENT_ID,
    });

    if (result === PAYWALL_RESULT.PURCHASED || result === PAYWALL_RESULT.RESTORED) {
      // Express intent. Do NOT navigate yet: the guard flips asynchronously
      // when the listener fires, so the route may not be mounted this tick.
      setPendingUnlock(true);
    }
  };

  // Navigate only once isPro has actually committed and the route is mounted.
  // This avoids a race where router.push targets a still-protected route and
  // gets redirected straight back to the tabs.
  useEffect(() => {
    if (pendingUnlock && isPro) {
      setPendingUnlock(false);
      router.replace('/analytics'); // group segment "(premium)" is not part of the URL
    }
  }, [pendingUnlock, isPro, router]);

  return (
    <View style={{ flex: 1, justifyContent: 'center', padding: 24, gap: 16 }}>
      <Text>Free content for everyone.</Text>
      <Button title="Unlock Premium" onPress={unlockPremium} />
    </View>
  );
}

presentPaywallIfNeededPAYWALL_RESULT に解決されます。 PURCHASEDRESTOREDCANCELLEDERRORNOT_PRESENTED(ユーザーがすでにエンタイトルメントを持っていて、何も表示されなかった場合に返ります)のいずれかです。

なぜ await の直後ではなくエフェクトからナビゲートするのか? 購入が成功すると、非同期の CustomerInfo リスナーを通じて isPro が切り替わります。ここで router.push を すぐに呼ぶと、Stack.Protected ガードがまだ再レンダーされておらず、プレミアムルートが存在しないため、 ユーザーは戻されてしまいます。useEffect の中で isPro を待てば、ルートが先にマウントされていることが保証されます。 規模の大きなアプリでは、この状態を context provider で共有し、画面ごとに一つずつではなく一つのリスナーだけを登録してください。
全画面のペイウォールルートにしたいですか? ペイウォールを <RevenueCatUI.Paywall onDismiss={...} /> コンポーネントで画面としてレンダーし、そこへナビゲートすることもできます。 「アンロック」ボタンには、命令的な presentPaywallIfNeeded がもっともコードの少ない方法です。

購入で自動アンロックする

購入結果を手動でガードに配線し直す必要はありません。購入(または復元)が成功すると、RevenueCat がステップ 4 の CustomerInfo リスナーを発火します。すると isProtrue になり、 Stack.Protected ガードが切り替わって、プレミアムルートがマウントされます。前のステップでは ナビゲートする前に isPro の切り替わりを(useEffect 内で)待つので、リダイレクトは必ず 存在するルートに着地します。

ペイウォール UI ではなく独自のボタンを作りたい場合は、パッケージを直接購入します。

tsx
import Purchases from 'react-native-purchases';
import { ENTITLEMENT_ID } from '../../hooks/useProAccess';

async function buyPro() {
  const offerings = await Purchases.getOfferings();
  const pkg = offerings.current?.availablePackages[0];
  if (!pkg) return;

  try {
    const { customerInfo } = await Purchases.purchasePackage(pkg);
    if (typeof customerInfo.entitlements.active[ENTITLEMENT_ID] !== 'undefined') {
      // Unlocked. The listener will also pick this up and flip the guard.
    }
  } catch (e: any) {
    if (!e.userCancelled) {
      // Show a real error; userCancelled just means the user backed out.
      console.warn('Purchase failed', e);
    }
  }
}

設定画面に「Restore Purchases」ボタンを追加し、新しい端末のユーザーがアクセスを取り戻せるようにします。

tsx
// app/(tabs)/settings.tsx
import { View, Button } from 'react-native';
import Purchases from 'react-native-purchases';

export default function Settings() {
  const restore = async () => {
    try {
      await Purchases.restorePurchases();
      // No navigation needed: the listener flips the guard if access was restored.
    } catch (e) {
      console.warn('Restore failed', e);
    }
  };

  return (
    <View style={{ flex: 1, justifyContent: 'center', padding: 24 }}>
      <Button title="Restore Purchases" onPress={restore} />
    </View>
  );
}

これらのメソッドの詳細は、 React Native で購入を復元するプロダクトと価格を取得するのガイドを参照してください。

リダイレクトのちらつきを防ぐ

避けるべき、ひとつの微妙なバグがあります。isProfalse から始まり、最初の getCustomerInfo() 呼び出しは非同期です。ガード付きスタックをすぐにレンダーすると、 チェックが完了する前に、課金済みのユーザーが一瞬だけ無料と見なされ、プレミアムルートから追い出される場合があります。

修正方法は、Expo が認証について推奨するものと同じパターンです。最初のエンタイトルメントチェックが完了するまで、 スプラッシュ画面を保持し続けます。フックの isReady は、まさにそのためのものです。

tsx
// app/_layout.tsx (final)
import { useEffect } from 'react';
import { Platform } from 'react-native';
import { Stack, SplashScreen } from 'expo-router';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
import { useProAccess } from '../hooks/useProAccess';

const API_KEYS = {
  apple: 'appl_xxxxxxxxxxxxxxxxxxxxxxxx',
  google: 'goog_xxxxxxxxxxxxxxxxxxxxxxxx',
};

// Run once at module load, BEFORE any component renders. Configuring here (not
// inside an effect) guarantees the SDK is ready before the hook's first
// getCustomerInfo() call, because a child's effects run before its parent's.
SplashScreen.preventAutoHideAsync();
Purchases.setLogLevel(LOG_LEVEL.VERBOSE);
if (Platform.OS === 'ios') {
  Purchases.configure({ apiKey: API_KEYS.apple });
} else if (Platform.OS === 'android') {
  Purchases.configure({ apiKey: API_KEYS.google });
}

// When a protected route is blocked, redirect to the tabs.
export const unstable_settings = { anchor: '(tabs)' };

export default function RootLayout() {
  const { isPro, isReady } = useProAccess();

  useEffect(() => {
    if (isReady) SplashScreen.hideAsync();
  }, [isReady]);

  // Hold the splash until the first entitlement check resolves.
  if (!isReady) return null;

  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Protected guard={isPro}>
        <Stack.Screen name="(premium)" options={{ headerShown: false }} />
      </Stack.Protected>
    </Stack>
  );
}
なぜモジュールスコープで設定するのか。 React では、子のエフェクトが親のエフェクトより先に実行されます。 Purchases.configure をレイアウトの useEffect 内で呼ぶと、フックの最初の getCustomerInfo() が SDK の設定より前に走って失敗し、コールドスタート時に課金済みのユーザーが 「無料」に見えてしまうことがあります。モジュールの読み込み時に(preventAutoHideAsync のそばで)設定すれば、 どの画面がレンダーされるより前に、一度だけ同期的に実行されます。

テストとまとめ

フロー全体をテストする

  1. 実機で development build を実行します。npx expo start --dev-client
  2. サンドボックステスター(App Store Connect サンドボックス、または Google Play のライセンステスター)でサインインします。
  3. 無料ユーザーとして、プレミアムルートに到達できないことを確認します(そこへのディープリンクはタブへリダイレクトされます)。
  4. Unlock Premium をタップしてサンドボックス購入を完了し、アプリが自動でプレミアム画面へナビゲートする様子を確認します。
  5. 削除して再インストールし、Restore Purchases をタップしてアクセスが戻ることを確認します。
サンドボックスは遅いです。 Apple のサンドボックスは、購入の完了に 15 秒以上かかることがあります。 これは正常です。オファリングが空で返ってくる場合は、プロダクトがエンタイトルメントとオファリングに紐付いているか、 そして development build(Expo Go ではなく)で動かしているかを、あらためて確認してください。

作ったもの

<Stack.Protected> で React Native のルートを RevenueCat のサブスクリプションでゲートし、 ロック中のユーザーにペイウォールを表示し、CustomerInfo リスナーを通じてアクセスが自動で切り替わるようにしました。 さらに、リダイレクトのちらつきを防ぐスプラッシュゲートも用意しました。同じパターンは複数のティアにも拡張できます。 Stack.Protected ガードをネストするか、エンタイトルメントの真偽値を増やしてください。

次に進む