Recipe · checked November 7, 2026
Phone app with in-app subscriptions
An iOS and Android app with a paid subscription, built once in React Native with Expo. The stores' own in-app purchase handles payment (Apple requires it), RevenueCat wraps both stores, and Supabase is the backend: database, sign-in and storage behind row-level security.
Fits when
- The product is an app installed from the App Store and Google Play.
- Users pay you for digital features or content: a subscription, a premium tier, or one-time unlocks.
- You know JavaScript or React and want one codebase for both platforms.
Not for
- Apps where users pay each other for real-world services (rides, walks, cleaning): those are paid outside in-app purchase. Use the marketplace recipe.
- Games with heavy 3D graphics: use a game engine instead of React Native.
- Apps that must work fully offline with large local data: plan local storage and sync before picking a hosted backend.
The stack
| Category | Pick | Why | Pick something else when |
|---|---|---|---|
| Mobile builds | Expo (EAS) | React Native with cloud builds for both stores, store submission, and over-the-air updates for JavaScript fixes. No Mac needed to build for iOS. |
|
| Database | Supabase | Postgres with an API the app can call directly, protected by row-level security tied to the signed-in user, plus file storage and edge functions in the same project. |
|
| Auth | Supabase Auth | Same project and client library as the database, so row-level security can use the signed-in user's id directly. Supports Sign in with Apple, which Apple requires if you offer Google sign-in. |
|
| Payments | RevenueCat | One SDK and one dashboard over Apple's and Google's in-app purchase, with entitlements, receipt checks, paywalls and webhooks to your backend. Free until $2,500 in monthly tracked revenue. |
|
| Emailoptional | Resend | Sign-in codes and receipts-style notices from your own domain once Supabase's built-in sender is too limited for production. |
|
| Monitoring | Sentry | Crash and error reports from both platforms with readable stack traces, including errors from over-the-air updates. |
|
| Analyticsoptional | PostHog | A React Native SDK for product analytics and funnels, so you can see where people drop off before the paywall. | — |
About $19/month at 1,000 users in tool bills: Expo (EAS) $19; $0 for Supabase, Supabase Auth, RevenueCat, Sentry. Usage profile: 1,000 monthly active users with light use: about 100,000 requests, 3,000 emails, 200,000 analytics events and 5 GB of stored files a month, and a database under 1 GB. AI apps add 50 questions per user a month at about 4,000 input and 500 output tokens each (200M input and 25M output tokens). Internal tools are priced for 10 staff instead.
Catches that change the plan
- Apple requires in-app purchase to unlock features or subscriptions inside an iOS app (Guideline 3.1.1). On the US storefront you may also link to buy on the web (3.1.1(a)). Source
- Store fees: Apple takes 15% under the Small Business Program (up to $1M in proceeds the prior year); otherwise you keep 70% in a subscriber's first year and 85% after a year of paid service. Google Play takes 10% plus a 5% billing fee on auto-renewing subscriptions for the first $1M in the US, EEA, UK, Australia and Japan. Source
- Both stores require in-app account deletion when the app lets people create accounts (Apple 5.1.1(v)); Google Play also wants a web link for deletion requests. Source
- Supabase's free plan pauses a project after a week with no activity and allows 2 active projects; Pro is from $25 a month and isn't paused. Source
Setup steps
Open the store accounts and create the app
Store accounts take days to approve, so start them first. Then create one Expo project that builds for both platforms.
- Enroll in the Apple Developer Program ($99 a year) and open a Google Play Console account ($25 once). Organizations need a D-U-N-S number for Apple; individuals don't.
- If you qualify (under $1M in App Store proceeds last calendar year, or new to the store), apply for Apple's App Store Small Business Program for the 15% rate before your first sale.
- Pick one bundle identifier (for example com.yourco.habits) and use it on both platforms.
- Create the app with Expo and link it to EAS. You need a development build, not Expo Go, because in-app purchases use native code.
- Commit an app.config.ts that reads environment variables, and keep secrets out of the app: anything shipped in the app binary is public.
npx create-expo-app@latest habit-app cd habit-app && npm i -g eas-cli && eas login && eas init eas build:configure npx expo install expo-dev-client eas build --profile development --platform allDone when: A development build installs on a real iPhone and Android phone and loads your code from the dev server.
Docs: docs.expo.dev, revenuecat.com, developer.apple.com, developer.apple.com
Set up the backend with row-level security
The app talks to Supabase directly with the public anon key, so every table must have row-level security on, with policies keyed to the signed-in user.
- Create a Supabase project. Keep the free plan while building; it pauses after a week with no activity, so move to Pro ($25/month) before launch if traffic might go quiet.
- Create a profiles table keyed by auth.users id, your app tables, and an entitlements table that only the server writes.
- Turn on row-level security on every table. Users may read and write only their own rows; entitlements are read-only to users.
- Store the Supabase URL and anon key in the app config. Never put the service role key in the app.
- Write migrations with the Supabase CLI and apply them from CI, not from the dashboard.
npm i -g supabase supabase init supabase migration new init npx expo install @supabase/supabase-js @react-native-async-storage/async-storagesupabase/migrations/0001_init.sqlcreate table profiles ( id uuid primary key references auth.users(id) on delete cascade, display_name text, created_at timestamptz not null default now() ); create table habits ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, name text not null, created_at timestamptz not null default now() ); create index on habits (user_id); -- Written only by the RevenueCat webhook (service role). The app reads it. create table entitlements ( user_id uuid primary key references auth.users(id) on delete cascade, entitlement text not null, -- e.g. 'pro' active boolean not null, expires_at timestamptz, store text, -- 'app_store' | 'play_store' | 'stripe' updated_at timestamptz not null default now() ); create table revenuecat_events ( -- webhook idempotency id text primary key, received_at timestamptz not null default now() ); alter table profiles enable row level security; alter table habits enable row level security; alter table entitlements enable row level security; alter table revenuecat_events enable row level security; -- no policies: server only create policy "own profile" on profiles for all using (auth.uid() = id) with check (auth.uid() = id); create policy "own habits" on habits for all using (auth.uid() = user_id) with check (auth.uid() = user_id); create policy "read own entitlement" on entitlements for select using (auth.uid() = user_id);Done when: Signed in as user A, a query for user B's habits returns nothing, and an insert into entitlements from the app is refused.
Docs: supabase.com, supabase.com
Add sign-in, Sign in with Apple and account deletion
Both stores require in-app account deletion when the app lets people create accounts, and Apple requires an equivalent private login option if you offer Google or another social login.
- Turn on email sign-in with one-time codes, and Sign in with Apple. Add Google sign-in only alongside Apple (App Review Guideline 4.8).
- Use the native Apple sign-in sheet on iOS and pass its identity token to Supabase.
- Persist the session with AsyncStorage (or SecureStore for the refresh token) so users stay signed in.
- Let people use the free part of the app before an account if you can; Apple reviewers reject forced sign-up for features that don't need an account.
- Build Delete account in Settings: an edge function, called with the user's token, deletes their rows and the auth user with the service role key. Also publish a web page that explains how to request deletion, for Google Play's form.
- Deleting the account doesn't cancel a store subscription. Tell the user to cancel in their App Store or Google Play settings, and link there.
npx expo install expo-apple-authentication expo-secure-storesupabase/functions/delete-account/index.tsimport { createClient } from "npm:@supabase/supabase-js@2"; Deno.serve(async (req) => { const jwt = req.headers.get("Authorization")?.replace("Bearer ", "") ?? ""; const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!); const { data, error } = await admin.auth.getUser(jwt); if (error || !data.user) return new Response("unauthorized", { status: 401 }); // Rows that reference auth.users with on delete cascade go with the user. const { error: delErr } = await admin.auth.admin.deleteUser(data.user.id); if (delErr) return new Response("failed", { status: 500 }); return new Response("deleted"); });Done when: A test account can sign in with Apple, delete itself from Settings, and can no longer sign in; its rows are gone.
Create the subscription in both stores and in RevenueCat
Apple and Google each hold the store products. RevenueCat maps them to one entitlement (for example pro) that your code checks.
- In App Store Connect, create a subscription group and an auto-renewable subscription (for example pro_monthly at $7.99). Subscription periods must be at least seven days (Guideline 3.1.2).
- In Google Play Console, create a subscription with the same product id and a base plan at the same price.
- Have the Account Holder accept the Paid Apps Agreement in App Store Connect, and add tax and banking details; the agreement must be active before in-app purchases work, even in sandbox testing.
- In RevenueCat, add both apps, connect the App Store Connect API key and the Google Play service account, create an entitlement pro, attach both products, and put them in an offering.
- Install the RevenueCat SDK and configure it after sign-in with the Supabase user id as the app user id, so purchases follow the account across devices.
npx expo install react-native-purchases react-native-purchases-uisrc/purchases.tsimport { Platform } from "react-native"; import Purchases from "react-native-purchases"; // Public SDK keys from the RevenueCat dashboard (safe to ship in the app). const KEYS = { ios: process.env.EXPO_PUBLIC_RC_IOS_KEY!, android: process.env.EXPO_PUBLIC_RC_ANDROID_KEY! }; export function configurePurchases(userId: string) { Purchases.configure({ apiKey: Platform.OS === "ios" ? KEYS.ios : KEYS.android, appUserID: userId }); } export async function isPro(): Promise<boolean> { const info = await Purchases.getCustomerInfo(); return info.entitlements.active["pro"] !== undefined; } export async function restore(): Promise<boolean> { const info = await Purchases.restorePurchases(); return info.entitlements.active["pro"] !== undefined; }Done when: In a development build, the offering loads with the store's localized price on both platforms.
Docs: revenuecat.com, developer.apple.com, developer.apple.com
Build a paywall the reviewers will pass
Most subscription rejections are about the paywall's wording, not the code.
- Say clearly what the subscriber gets, the price and the billing period before they tap subscribe (Guideline 3.1.2).
- Show the store's localized price string from the offering; never hard-code $7.99.
- Include a Restore Purchases button and links to your terms and privacy policy on the paywall.
- If you offer a free trial, say when it converts and what it will cost.
- Use RevenueCat's paywall component or your own screen; either way, unlock features only from the entitlement, never from a local flag.
Done when: A tester who reads only the paywall can say what they'd pay, how often, and how to restore a purchase.
Docs: developer.apple.com
Mirror entitlements to the backend with a webhook
The app checks RevenueCat for the paywall, but anything the server does for paying users (sync, AI calls, exports) must check the entitlement your backend stored from the webhook.
- Add a RevenueCat webhook pointing at a Supabase edge function, with a long random Authorization header value.
- In the function, reject requests without that header, skip event ids already stored, and upsert the user's entitlement.
- Handle at least INITIAL_PURCHASE, RENEWAL, CANCELLATION (still active until expiry), EXPIRATION, BILLING_ISSUE and PRODUCT_CHANGE.
- On any event, you can re-fetch the subscriber from RevenueCat's API instead of trusting the payload's order.
- Gate server features on entitlements.active and expires_at.
supabase/functions/revenuecat-webhook/index.tsimport { createClient } from "npm:@supabase/supabase-js@2"; const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!); Deno.serve(async (req) => { if (req.headers.get("Authorization") !== `Bearer ${Deno.env.get("RC_WEBHOOK_SECRET")}`) { return new Response("unauthorized", { status: 401 }); } const { event } = await req.json(); const { error: dup } = await admin.from("revenuecat_events").insert({ id: event.id }); if (dup) return new Response("ok"); // already processed const active = !["EXPIRATION"].includes(event.type); await admin.from("entitlements").upsert({ user_id: event.app_user_id, // the Supabase user id you passed to Purchases.configure entitlement: "pro", active, expires_at: event.expiration_at_ms ? new Date(event.expiration_at_ms).toISOString() : null, store: String(event.store ?? "").toLowerCase(), updated_at: new Date().toISOString(), }); return new Response("ok"); });Done when: A sandbox purchase sets the user's entitlement to active within a minute, and its expiry clears it.
Docs: revenuecat.com
Test purchases end to end
Both stores have test accounts that renew subscriptions quickly, so you can see a whole lifecycle in an hour.
- Create Sandbox Apple Accounts in App Store Connect and license testers in Play Console.
- Test buy, renew, cancel, expire, restore on a second device, and a failed payment (billing issue).
- Test signing in on a new phone: the subscription must follow the account through restore.
- Upload a build to TestFlight and Play internal testing and buy there too; that's closest to what reviewers see.
eas build --profile preview --platform allDone when: Every lifecycle event shows up in RevenueCat and in your entitlements table, on both platforms.
Add crash reporting and analytics
Know about crashes before reviews mention them, and see where people drop off before the paywall.
- Install Sentry with its wizard and upload source maps from EAS builds and updates.
- Install PostHog and track a short funnel: opened, signed up, saw paywall, subscribed.
- Identify users by the Supabase user id only; don't send emails or names to analytics.
- List every SDK that collects data in the App Store privacy details and Google Play's Data safety form.
npx @sentry/wizard@latest -i reactNative npx expo install posthog-react-nativeDone when: A deliberate test crash appears in Sentry with a readable stack trace, and the paywall funnel shows in PostHog.
Docs: docs.sentry.io, posthog.com
Submit to both stores
Build production binaries in the cloud and submit them from the command line.
- Build production binaries for both platforms and submit them with EAS Submit.
- Give App Review a demo account and a note on where the subscription is and how to test it.
- Fill in App Store privacy details, Google Play's Data safety form, content rating and the account deletion web link.
- Personal Google Play accounts created after November 13, 2023 must run a closed test with at least 12 testers opted in for 14 days in a row before applying for production access, so start it early.
- Ship JavaScript-only fixes with EAS Update; anything that changes native code or what the app does needs a new store build and review.
eas build --profile production --platform all eas submit --platform ios eas submit --platform androidDone when: Both apps are approved, and a real purchase in production unlocks pro and appears in RevenueCat.
Optional: sell on the web too
Web purchases avoid store fees but come with rules. Do this after the in-app subscription works.
- On the US storefront, apps may include a button or link to buy on the web (Guideline 3.1.1(a)); elsewhere, don't point users from the app to outside purchase.
- Anything sold on the web that unlocks the app must also be available as an in-app purchase (Guideline 3.1.3(b)).
- Sell with Stripe Checkout on your site, grant the same pro entitlement from the Stripe webhook, and keep one entitlements table as the source of truth.
- On Android, external links and alternative billing need enrollment in Google's programs for eligible regions; Google Play billing stays the default.
Done when: A web purchase unlocks pro in the app on next launch, and the in-app purchase still works.
Best practices
- Make the store purchase the source of truth through RevenueCat, and mirror it to your backend from webhooks.
- Use the backend user id as RevenueCat's app user id so a subscription follows the account, not the phone.
- Turn on row-level security on every table before the first build ships; the anon key in the app is public.
- Keep the free part of the app usable without an account when you can; ask for sign-in when it adds value.
- Show localized prices from the store and never hard-code amounts.
- Plan for store review time in every release; use over-the-air updates only for JavaScript fixes.
Pitfalls
- Adding Stripe checkout inside the iOS app for a digital subscription: Apple rejects it (Guideline 3.1.1).
- Testing purchases in Expo Go, where native purchase code doesn't run; use a development build.
- Offering Google sign-in on iOS without Sign in with Apple or another equivalent private option (Guideline 4.8).
- Shipping without in-app account deletion; both stores require it when the app creates accounts.
- Unlocking features from a value saved on the phone; reinstalling or tampering bypasses it.
- Forgetting the Paid Apps Agreement in App Store Connect, so products never load in sandbox testing.
- Shipping the Supabase service role key inside the app.
Costs and obligations
- Store accounts: Apple Developer Program $99 a year (about $8.25 a month) and Google Play $25 once.
- Store fees come out of revenue: at 100 subscribers paying $7.99 a month ($799), about 15% ($120) goes to the stores under Apple's Small Business Program and Google Play's subscription rate.
- RevenueCat is free up to $2,500 in monthly tracked revenue, then 1% of what you track; its page doesn't say whether that 1% is on all tracked revenue or only the part above $2,500.
- Apple's App Review Guidelines and Google Play's payments policy change often. Read the current sections on payments (Apple 3.1, Google Play payments policy) before you design the paywall.
- Both stores require privacy disclosures (App Store privacy details, Google Play Data safety) that match every SDK in the app, including analytics and crash reporting.
- Apps for children have extra rules in both stores and under laws such as COPPA in the US; this recipe doesn't cover them.
- If you have users in the EU or UK, you need a privacy policy that lists your vendors and data processing agreements with them.
- Auto-renewing subscriptions have consumer rules on disclosure and cancellation in many places; the stores handle billing and cancellation, but your paywall text is yours.
These point to obligations to check; they aren't legal or tax advice.