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-uiwhen a locked screen is requested. - Automatic unlocking: a successful purchase flips the guard through a
CustomerInfolistener. - A splash gate that prevents the "redirect flash" before the first entitlement check resolves.
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:
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:
npx expo install expo-dev-client
eas build --profile development --platform ios
# or: eas build --profile development --platform android
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.
npx expo install react-native-purchases react-native-purchases-ui
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:
// 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) rather than hardcoding them.
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.
// 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 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.
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:
// app/(premium)/_layout.tsx
import { Stack } from 'expo-router';
export default function PremiumLayout() {
return <Stack />;
}
The tabs layout declares the two always-public screens:
// 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.
// 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) 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).
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:
// 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).
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.
<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:
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:
// 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.
// 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>
);
}
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
- Run the development build on a device:
npx expo start --dev-client. - Sign in with a sandbox tester (App Store Connect Sandbox, or a Google Play license tester).
- As a free user, confirm the premium route is unreachable (deep link to it redirects to the tabs).
- Tap Unlock Premium, complete the sandbox purchase, and watch the app navigate into the premium screen automatically.
- Delete and reinstall, then tap Restore Purchases to confirm access returns.
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
- React Native In-App Purchases & Subscriptions: the full SDK integration codelab.
- CustomerInfo update listener: the listener API in depth.
- Configure the SDK and Get CustomerInfo: the building blocks used here.
- Expo Router: Protected routes and RevenueCat: Displaying Paywalls.