What You'll Build

In this codelab you will lock premium screens in a Expo Router app behind a RevenueCat subscription. A free user can browse the public screens, but the premium routes simply do not exist in the navigation tree until they hold the pro entitlement. When they purchase, the routes appear and the app navigates them in, with no manual refresh.

By the end you will have:

  • A tab app with a free Home and a subscription-only Analytics screen.
  • Route gating with Expo Router's <Stack.Protected>, driven by a RevenueCat entitlement.
  • A paywall shown with react-native-purchases-ui when a locked screen is requested.
  • Automatic unlocking: a successful purchase flips the guard through a CustomerInfo listener.
  • A splash gate that prevents the "redirect flash" before the first entitlement check resolves.
Why this pattern matters. Gating by a RevenueCat entitlement (not by an auth role) means access follows the subscription across devices and reinstalls automatically, because RevenueCat is the source of truth for subscription state.
New to the RevenueCat SDK? Skim the Configure the SDK and Get CustomerInfo guides first, then come back here.

Prerequisites & Project Setup

You need two things ready: an Expo project and a RevenueCat project.

1. An Expo app on SDK 53 or newer

<Stack.Protected> (also called Guarded Groups) shipped in Expo SDK 53 / Expo Router v5. Create a fresh app:

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

2. A development build (Expo Go will not work for purchases)

RevenueCat needs native modules, so real purchases do not run in Expo Go. You must use a development build. Add the dev client and create a build:

bash
npx expo install expo-dev-client
eas build --profile development --platform ios
# or: eas build --profile development --platform android
Expo Go note. react-native-purchases includes a Preview API Mode so the app will boot in Expo Go, but real purchases only work in a development build. Test purchases on a device.

3. A RevenueCat project with an entitlement, offering, and paywall

In the RevenueCat dashboard, set up:

  • An entitlement with the identifier pro (Product Catalog → Entitlements).
  • At least one product (configured in App Store Connect / Google Play) attached to that entitlement.
  • An offering (the default offering, available via offerings.current).
  • A paywall attached to that offering so a designed paywall appears (if none is configured, RevenueCatUI shows a default paywall).

Setting up the Google Play side? See the Google Play service account setup guide.

Install & Configure RevenueCat

Install both packages with expo install so the versions match your Expo SDK. Install them together: react-native-purchases-ui pins the matching react-native-purchases version.

bash
npx expo install react-native-purchases react-native-purchases-ui
No config plugin needed. These packages do not ship an Expo config plugin, so do not add them to the plugins array in app.json. Rebuild the dev build after installing native modules.

Configure the SDK once, as early as possible. In an Expo Router app that means the root layout, app/_layout.tsx. Use the public API key for each platform (iOS keys start with appl_, Google Play keys with goog_), and set the log level before configuring:

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 />;
}
Keep keys out of source control. For a real app, read the keys from environment variables (for example process.env.EXPO_PUBLIC_RC_IOS_KEY) rather than hardcoding them.
Heads up. This useEffect placement is fine while the layout only renders <Stack />. Once a screen reads the entitlement on launch (Step 4 onward), we move configure to module scope so it runs before that first read. Step 9 shows the final version and explains why.

Track Pro Access with a Hook

The route guard needs a single boolean: does the current user have the pro entitlement? Build a small hook that reads it once and then keeps it in sync by listening for changes.

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 };
}
Important: the listener returns void. addCustomerInfoUpdateListener does not return a subscription object, so const sub = addCustomerInfoUpdateListener(...); sub.remove() is wrong and will throw. You must keep the same function reference and pass it to removeCustomerInfoUpdateListener. See the CustomerInfo listener guide.

isReady is false until that first getCustomerInfo() resolves. We use it in Step 9 to avoid redirecting users before we actually know their entitlement.

Structure Your Routes

Expo Router is file-based: each file under app/ is a route. Put the always-public screens in a (tabs) group and the subscription-only screens in a separate (premium) group so they can be guarded as a unit.

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

The premium group's layout is just a normal stack. The gating happens one level up, in the root layout:

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

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

The tabs layout declares the two always-public screens:

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

Gate Routes with Stack.Protected

Now the core. Wrap the (premium) group in <Stack.Protected> and pass the entitlement boolean as the guard. When guard is false, those routes are removed from the navigation tree, and any attempt to open them (including a deep link) redirects to the first available unprotected screen.

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>
  );
}
The redirect target is the first unprotected screen. Because (tabs) is not wrapped in Stack.Protected, a free user who taps a deep link to /(premium)/analytics is sent back to the tabs. If you want them to land on a dedicated paywall route instead, leave that route unprotected and make it the anchor (via unstable_settings).
Client-side only. Protected routes are a navigation convenience, not a security boundary. Always enforce premium access on your server / with RevenueCat's entitlements for anything that matters.

Send Locked Users to a Paywall

A free user has no premium routes to navigate to, so give them a clear way in: an "Unlock Premium" button that presents a paywall. react-native-purchases-ui can present one only if the user is missing the entitlement:

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

presentPaywallIfNeeded resolves to a PAYWALL_RESULT: PURCHASED, RESTORED, CANCELLED, ERROR, or NOT_PRESENTED (returned when the user already has the entitlement, so nothing was shown).

Why navigate from an effect, not right after the await? A successful purchase flips isPro through the async CustomerInfo listener. If you call router.push immediately, the Stack.Protected guard may not have re-rendered yet, so the premium route does not exist and the user is redirected back. Waiting for isPro in a useEffect guarantees the route is mounted first. In a larger app, share this state through a context provider so you register one listener instead of one per screen.
Prefer a full-screen paywall route? You can also render the paywall as a screen with the <RevenueCatUI.Paywall onDismiss={...} /> component and navigate to it. The imperative presentPaywallIfNeeded is the least-code path for a "unlock" button.

Purchase to Auto-Unlock

You do not need to wire the purchase result back to the guard manually. When a purchase (or restore) succeeds, RevenueCat fires the CustomerInfo listener from Step 4, which sets isPro to true, which flips the Stack.Protected guard, which mounts the premium routes. Because the previous step waits for isPro to flip before navigating (inside a useEffect), the redirect then lands on a route that is guaranteed to exist.

If you would rather build your own button instead of the paywall UI, purchase a package directly:

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

Add a "Restore Purchases" button on a settings screen so users on a new device can regain access:

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

For more on these methods, see the Restore purchases in React Native and Get products and prices guides.

Prevent the Redirect Flash

There is one subtle bug to avoid. isPro starts as false, and the first getCustomerInfo() call is asynchronous. If you render the guarded stack immediately, a paying user can be briefly treated as free and redirected out of a premium route before the check resolves.

The fix is the same pattern Expo recommends for auth: keep the splash screen up until the first entitlement check resolves. That is exactly what isReady from the hook is for.

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>
  );
}
Why configure at module scope. In React a child's effects run before its parent's effects. If you call Purchases.configure inside the layout's useEffect, the hook's first getCustomerInfo() can run before the SDK is configured and fail, leaving a paying user looking "free" on cold start. Configuring at module load (next to preventAutoHideAsync) runs once, synchronously, before any screen renders.

Test It & Recap

Test the full flow

  1. Run the development build on a device: npx expo start --dev-client.
  2. Sign in with a sandbox tester (App Store Connect Sandbox, or a Google Play license tester).
  3. As a free user, confirm the premium route is unreachable (deep link to it redirects to the tabs).
  4. Tap Unlock Premium, complete the sandbox purchase, and watch the app navigate into the premium screen automatically.
  5. Delete and reinstall, then tap Restore Purchases to confirm access returns.
Sandbox is slow. Apple's sandbox can take 15 seconds or more to complete a purchase. That is normal. If offerings come back empty, double-check your products are attached to the entitlement and offering, and that you are on a development build (not Expo Go).

What you built

You gated React Native routes behind a RevenueCat subscription with <Stack.Protected>, presented a paywall to locked users, and made access flip automatically through a CustomerInfo listener, with a splash gate to prevent the redirect flash. The same pattern scales to multiple tiers: nest Stack.Protected guards or add more entitlement booleans.

Keep going