Add a paywall
Charge for your Expo app with RevenueCat, Adapty or Stripe Checkout - from store agreements to a sandbox purchase that unlocks a feature.
Hey - by the end of this page you'll open a paywall from your own screen, buy a subscription with a test account, watch a locked feature unlock, and restore the purchase.
The /paywall route ships as a modal with your feature bullets, the live prices, Buy, Restore
and the Terms / Privacy links from urls in readynative.config.ts. You add the products
behind it and the button that opens it.
Before you start
- Tier: Pro. The payments modules aren't in Free or Starter.
- Time: about 2 hours of your own work. Apple can take up to a day to activate a new Paid Apps Agreement, so start step 2 first.
- Runs in: a dev build on a real device. StoreKit and Play Billing are native, so Expo Go can't run them and simulators can't buy. See Expo Go or a dev build.
- Accounts: RevenueCat, the Apple Developer Program and a Google Play developer account.
- Previous tutorial: Add sign-in.
Picked a payments option at setup? Skip step 1. Step 1 needs modules/, so it works only
in a tree set up with --keep-modules; a finalized tree without payments starts from a fresh
clone - see Can I change a module after setup?.
1. Add the module
bun run setup --payments revenuecat --yes --keep-modulesYour stack toggle says payments=none, where presentPaywall doesn't exist and nothing is ever
unlocked. The steps below follow RevenueCat, the default; pick Adapty or Stripe in the toggle
above to switch them.
You should see: src/lib/payments.ts, src/app/paywall/ and
src/screens/paywall/paywall-screen.tsx in your tree.
2. Get paid-ready
No agreement, no prices
Until the Paid Apps Agreement is active in App Store Connect, StoreKit returns no products and your paywall shows no prices - with no error that says why. Sign it before anything else.
- App Store Connect → Business: accept the Paid Apps Agreement, then add a bank account and fill in the tax forms. The agreement shows "Active" once Apple has checked them.
- App Store Connect → Apps → +: create the app record with the production bundle id from
readynative.config.ts(the one without.dev), if you haven't yet. - Play Console → Settings → Payments profile: create or link a payments profile. Play lets you create subscriptions only once a build with billing has been uploaded, so do the Play side after Ship to Google Play - iOS is enough for this page.
You should see: the Paid Apps Agreement marked Active under App Store Connect → Business.
3. Create the product
In App Store Connect → your app → Subscriptions, create a subscription group, then a
subscription in it: a product id such as trailmix_pro_monthly, a duration, a price and one
localization. It stays "Ready to Submit" until your first app version goes to review - that's
fine for sandbox testing.
You should see: the product listed with its price.
4. Connect the dashboard and keys
Copy .env.example to .env if you haven't yet (cp .env.example .env).
- Create a project at app.revenuecat.com and add an App Store
app under Project settings → Apps with your bundle id. Add the
.devbundle id as well - your dev build uses it. (Add the Play Store app when you do the Play side.) - Project settings → API keys: copy the App Store key (
appl_…) into.env. - Products: add the App Store product from step 3.
- Entitlements: create one with the identifier
pro(PRO_ENTITLEMENTinsrc/lib/payments.ts) and attach the product. - Offerings: create an offering with a monthly package holding that product, and mark it current. The bundled paywall lists its packages.
- Optional: design a paywall under Paywalls and attach it to the current offering.
payments.presentPaywall()shows it natively when it exists and falls back to the bundled/paywallotherwise. Replace RevenueCat's sample copy before you ship.
EXPO_PUBLIC_REVENUECAT_IOS_KEY=appl_...
EXPO_PUBLIC_REVENUECAT_ANDROID_KEY=goog_...Leave the Android key empty until the Play side exists - bun run doctor keeps that one row
red until then. Want to try the flow before App Store Connect is ready? A RevenueCat Test
Store key (test_…) in the iOS slot shows RevenueCat's own purchase dialog instead of
Apple's - never ship it. Every step in full is on the
RevenueCat page.
bun run doctorYou should see: the payments rows green (apart from any key you're deliberately leaving for later).
5. Run it on a device
Add a sandbox tester in App Store Connect → Users and Access → Sandbox → Testers (a fresh
email, not your Apple ID). Then build the dev build onto your iPhone - bun run ios compiles it
locally, and --device picks the plugged-in phone:
bunx expo run:ios --deviceNo Mac or Xcode? Build it on EAS with bunx eas-cli build --profile development --platform ios
after registering your phone - Expo Go or a dev build walks
through it. Then start the dev server:
bun run start:devYou should see: your app running on the phone, signed in.
6. Gate a feature
@/lib/payments has the same API for every option: useEntitlements() returns
{ active, loading }, and presentPaywall() opens the paywall (RevenueCat's native one when you designed it, else the bundled /paywall). This
component shows its children to paying users and an unlock card to everyone else:
import type { ReactNode } from "react";
import { Box, Button, Card, Loading, Text, toast } from "@/components/ui";
import { payments } from "@/lib/payments";
/** The entitlement id from your payments dashboard. */
const ENTITLEMENT = "pro";
export function ProGate({ children }: { children: ReactNode }) {
const { active, loading } = payments.useEntitlements();
if (loading) return <Loading />;
if (active.includes(ENTITLEMENT)) return <>{children}</>;
const openPaywall = () => {
payments.presentPaywall?.().catch(() => {
toast.show({ title: "Couldn't open the paywall", kind: "error" });
});
};
return (
<Box p={4}>
<Card>
<Box gap={3}>
<Text variant="heading">This is a Pro feature</Text>
<Text variant="caption">Subscribe to unlock it.</Text>
<Button onPress={openPaywall}>Unlock Pro</Button>
</Box>
</Card>
</Box>
);
}Now wrap something with it. With the Notes tab from Add your first feature,
make writing notes a Pro feature - in src/screens/notes/notes-screen.tsx, import ProGate and
change the header prop of the List:
import { ProGate } from "@/components/pro-gate";
// ...in the <List> props:
header={
<ProGate>
<NoteForm onSave={add} />
</ProGate>
}You should see: the "This is a Pro feature" card instead of the form. Tap Unlock Pro and the paywall opens with your product's price.
7. Buy it
On the paywall, tap Buy. Apple's sheet asks you to sign in: use the sandbox tester from step 5. Sandbox subscriptions renew every few minutes, so you can watch renewals happen.
You should see: the paywall close and the note form appear where the unlock card was.
8. Restore it
Both stores require a Restore button - the bundled paywall has one, and it's one call anywhere else:
import { payments } from "@/lib/payments";
await payments.restorePurchases();You should see: after deleting and reinstalling the app, Restore on the paywall brings the form back without paying again.
Check it
bun run doctorbun run typecheckEvery payments row should be green, and typecheck should pass.
If it doesn't work
- The paywall has no prices or no packages - the Paid Apps Agreement isn't active, the product isn't attached, or the offering isn't current. See RevenueCat shows no offerings or no prices.
- The paywall says "Configure …" - the key for this platform isn't in
.env, or Metro still has the old env. Restart withbun run start -- -c. - "Native module not found" or a red screen on launch - you opened the app in Expo Go. Use
the dev build and
bun run start:dev. - The buy sheet never appears in the simulator - buy on a real device with a sandbox tester.
- Purchases work for you but not for a tester - they're signed into the App Store with a real Apple ID; sandbox needs the sandbox account (Settings → App Store → Sandbox Account).
More in Troubleshooting.
Congrats 🎉
You're selling: a paywall with live prices, a feature behind an entitlement, and restore - which is what App Review checks first. To take the module out again, see RevenueCat → Remove it.
Next, reach users when the app is closed: Add push notifications.