# ReadyNative > Pick-your-stack Expo SDK 57 starter: run setup once, keep only the modules you chose, ship. A one-time-payment Expo (React Native) boilerplate for iOS and Android: one `setup` command assembles only the modules you pick (auth, payments, push, analytics, crash reporting, data, forms, i18n and more), with EAS store builds and a `doctor` check wired in. ## Product - [Home](https://readynative.app/): what ReadyNative is and what the setup command does. - [Pricing](https://readynative.app/#pricing): Free, Starter and Pro tiers - one payment, lifetime updates. - [Stacks](https://readynative.app/stack/): every module you can pick, one page each. - [Comparisons](https://readynative.app/vs/): ReadyNative next to other Expo and React Native boilerplates. - [Best Expo boilerplates](https://readynative.app/best-expo-boilerplates/): a sourced roundup of the options. - [Free tier on GitHub](https://github.com/ReadyNative/ready-native-free): the public repository. # Add a paywall (/docs/add-a-paywall) Pro 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 [#before-you-start] * **Tier:** Pro. The payments modules aren't in Free or Starter. * **Time:** about 2 hours of your own work. Stripe test mode is live as soon as you sign up.Apple can take up to a day to activate a new Paid Apps Agreement, so start step 2 first. * **Runs in:** Expo Go - Checkout is a web page, with no native SDK.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](https://readynative.app/docs/expo-go-vs-dev-build). * **Accounts:** [Stripe](https://stripe.com), and an auth module (Supabase, Clerk or Better Auth) - Checkout charges the signed-in user.[Adapty](https://app.adapty.io), the Apple Developer Program and a Google Play developer account.[RevenueCat](https://app.revenuecat.com), the Apple Developer Program and a Google Play developer account. * **Previous tutorial:** [Add sign-in](https://readynative.app/docs/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?](https://readynative.app/docs/faq#can-i-change-a-module-after-setup). ## 1. Add the module [#1-add-the-module] bun run setup --payments adapty --yes --keep-modules bun run setup --payments stripe --backend api-routes --yes --keep-modules Checkout runs through your API routes, so Stripe requires `backend=api-routes` (setup refuses it with any other backend). It also needs to know who's paying: if you have no auth module yet, add `--auth supabase` (or `clerk`, `better-auth`) to the same command. This page sells through Stripe alone. To offer in-app purchase and Stripe Checkout side by side, see [Add web checkout next to the App Store](https://readynative.app/docs/add-web-checkout). bun run setup --payments revenuecat --yes --keep-modules Your 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 [#2-get-paid-ready] Stay in **test mode** for this whole page: test keys, test prices, test cards. Activating your Stripe account (business details, bank account) matters only when you switch to live mode. 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. 1. **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. 2. **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. 3. **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](https://readynative.app/docs/ship-to-google-play) - iOS is enough for this page. **You should see:** the **Test mode** toggle on in the Stripe dashboard.the Paid Apps Agreement marked **Active** under App Store Connect → Business. ## 3. Create the product [#3-create-the-product] In Stripe, open **Product catalog → Add product**, give it a name, and add a **recurring** price (for example monthly). Copy the price id (`price_…`). 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 [#4-connect-the-dashboard-and-keys] Copy `.env.example` to `.env` if you haven't yet (`cp .env.example .env`). 1. Create an app at [app.adapty.io](https://app.adapty.io) and add your iOS bundle id and Android package under **App settings**. Add the `.dev` bundle id too - your dev build uses it. 2. **App settings → General**: copy the Public SDK key into `.env`. 3. **Products**: add the App Store product from step 3. 4. **Access levels**: use `premium` (Adapty's built-in default) and attach your product. 5. **Paywalls**: build one with the Paywall Builder, then attach it to a **Placement** - `default`, unless you set `EXPO_PUBLIC_ADAPTY_PLACEMENT_ID`. ```bash title=".env" EXPO_PUBLIC_ADAPTY_PUBLIC_KEY=public_live_... ``` Every step in full is on the [Adapty page](https://readynative.app/docs/features/payments/adapty#setup). 1. **Developers → API keys** (test mode): the secret key goes into `STRIPE_SECRET_KEY` - never with an `EXPO_PUBLIC_` prefix, which would ship it to every device - and the publishable key into `EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY`. 2. The price id from step 3 goes into `STRIPE_PRICE_ID`. 3. Webhooks: install the [Stripe CLI](https://docs.stripe.com/stripe-cli) and forward events to your dev server. It prints a signing secret (`whsec_…`) for `STRIPE_WEBHOOK_SECRET`. 4. `EXPO_PUBLIC_API_URL` is your dev server on the LAN (a phone can't reach `localhost`). 5. With Clerk, also set `CLERK_SECRET_KEY` so the routes can verify the session token. ```bash stripe listen --forward-to localhost:8081/api/stripe/webhook ``` ```bash title=".env" EXPO_PUBLIC_API_URL=http://192.168.1.20:8081 EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_SECRET_KEY=sk_test_... STRIPE_PRICE_ID=price_... STRIPE_WEBHOOK_SECRET=whsec_... ``` Leave `stripe listen` running while you test. Every step in full is on the [Stripe page](https://readynative.app/docs/features/payments/stripe#setup). 1. Create a project at [app.revenuecat.com](https://app.revenuecat.com) and add an App Store app under **Project settings → Apps** with your bundle id. Add the `.dev` bundle id as well - your dev build uses it. (Add the Play Store app when you do the Play side.) 2. **Project settings → API keys**: copy the App Store key (`appl_…`) into `.env`. 3. **Products**: add the App Store product from step 3. 4. **Entitlements**: create one with the identifier `pro` (`PRO_ENTITLEMENT` in `src/lib/payments.ts`) and attach the product. 5. **Offerings**: create an offering with a monthly package holding that product, and mark it **current**. The bundled paywall lists its packages. 6. 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 `/paywall` otherwise. Replace RevenueCat's sample copy before you ship. ```bash title=".env" 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](https://readynative.app/docs/features/payments/revenuecat#setup). bun run doctor **You should see:** the payments rows green (apart from any key you're deliberately leaving for later). ## 5. Run it on a device [#5-run-it-on-a-device] bun run start Open the app in Expo Go and sign in - the paywall charges the signed-in user. 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 --device No 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](https://readynative.app/docs/expo-go-vs-dev-build) walks through it. Then start the dev server: bun run start:dev **You should see:** your app running on the phone, signed in. ## 6. Gate a feature [#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`) (the Paywall Builder one for your placement, else the bundled `/paywall`) (the bundled `/paywall`, which hands off to Checkout). This component shows its children to paying users and an unlock card to everyone else: ```tsx title="src/components/pro-gate.tsx" 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 ; if (active.includes(ENTITLEMENT)) return <>{children}; const openPaywall = () => { payments.presentPaywall?.().catch(() => { toast.show({ title: "Couldn't open the paywall", kind: "error" }); }); }; return ( This is a Pro feature Subscribe to unlock it. ); } ``` On Adapty the id is the access level, so set `const ENTITLEMENT = "premium";`. Now wrap something with it. With the Notes tab from [Add your first feature](https://readynative.app/docs/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`: ```tsx title="src/screens/notes/notes-screen.tsx" import { ProGate } from "@/components/pro-gate"; // ...in the props: header={ } ``` **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 [#7-buy-it] On the paywall, tap **Subscribe**. Checkout opens in the browser: pay with the test card `4242 4242 4242 4242`, any future expiry, any CVC. The browser returns to the app, the webhook lands (watch the `stripe listen` output), and the entitlement refreshes. 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 [#8-restore-it] Both stores require a Restore button - the bundled paywall has one, and it's one call anywhere else: ```ts import { payments } from "@/lib/payments"; await payments.restorePurchases(); ``` On Stripe, restore re-fetches the entitlements from your server, which also makes it the "refresh after a purchase" call. **You should see:** after deleting and reinstalling the app, **Restore** on the paywall brings the form back without paying again. ## Check it [#check-it] bun run doctor bun run typecheck Every payments row should be green, and typecheck should pass. ## If it doesn't work [#if-it-doesnt-work] * **The paywall says "Configure Stripe"** - `EXPO_PUBLIC_API_URL` is empty or unreachable. Set it to your LAN IP and restart with `bun run start -- -c`. * **"Sign in to subscribe"** - Checkout charges the signed-in user; sign in first, or add an auth module. * **Paid, but still locked** - the webhook didn't arrive. Check `stripe listen` is running and `STRIPE_WEBHOOK_SECRET` is the secret it printed, then tap **Restore**. * **401 or 500 from `/api/stripe/*` with Clerk** - `CLERK_SECRET_KEY` is missing. * **iOS refuses the request** - plain `http://` works only on a LAN IP; a tunnel or deployed URL must be https. * **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](https://readynative.app/docs/troubleshooting#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 with `bun 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](https://readynative.app/docs/troubleshooting). ## Congrats 🎉 [#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 [Adapty → Remove it](https://readynative.app/docs/features/payments/adapty#remove-it)[Stripe → Remove it](https://readynative.app/docs/features/payments/stripe#remove-it)[RevenueCat → Remove it](https://readynative.app/docs/features/payments/revenuecat#remove-it). Next, reach users when the app is closed: [Add push notifications](https://readynative.app/docs/add-push). # Add analytics and crash reporting (/docs/add-analytics-and-crash) Pro Hey - by the end of this page your events show up in your analytics dashboard, a test error shows up in Sentry with a readable stack trace, and you've watched both stay silent until the user says yes. That last part is the consent module's job. `setup` adds it whenever you pick an analytics or crash option: PostHog, Amplitude and Sentry create their clients only once the matching category is consented to, so nothing - no event, no flags request, no crash report - leaves the device before that. ## Before you start [#before-you-start] * **Tier:** Pro. Analytics and crash modules aren't in Free or Starter. * **Time:** about 30 minutes, plus a build if you want native crashes and source maps checked. * **Runs in:** Expo Go for events and JS errors. Native crashes and Amplitude's full device context need a dev build - see [Expo Go or a dev build](https://readynative.app/docs/expo-go-vs-dev-build). * **Accounts:** [Amplitude](https://app.amplitude.com)[PostHog](https://app.posthog.com) and [Sentry](https://sentry.io) - both have free tiers. * **Previous tutorial:** [Add push notifications](https://readynative.app/docs/add-push). **Picked these 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 them starts from a fresh clone - see [Can I change a module after setup?](https://readynative.app/docs/faq#can-i-change-a-module-after-setup). ## 1. Add the modules [#1-add-the-modules] bun run setup --analytics amplitude --crash sentry --yes --keep-modules bun run setup --analytics posthog --crash sentry --yes --keep-modules Drop either flag if you want only one of them. `consent` comes along on its own. If you install with bun on a tree that already existed, run `bun pm trust @sentry/cli` once so its source-map uploader can download. **You should see:** `src/lib/analytics.ts`, `src/lib/crash.ts`, `src/lib/consent.ts` and `src/components/consent-sheet.tsx` in your tree, and a **Privacy** card in Settings with **Analytics** and **Crash reports** switches. ## 2. Paste the keys [#2-paste-the-keys] Copy `.env.example` to `.env` if you haven't yet (`cp .env.example .env`). In Amplitude, open **Settings → Projects → your project** and copy the API key. For EU data residency, add `serverZone: "EU"` to the `init` options in `src/lib/analytics.ts`. ```bash title=".env" EXPO_PUBLIC_AMPLITUDE_KEY=your-amplitude-api-key ``` In PostHog, open **Settings → Project** and copy the Project API key (`phc_…`). EU project? Set the host too; it defaults to the US one. ```bash title=".env" EXPO_PUBLIC_POSTHOG_KEY=phc_... EXPO_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com ``` In Sentry, create a project (platform: React Native), open **Settings → Projects → your project → Client Keys (DSN)**, and copy the DSN: ```bash title=".env" EXPO_PUBLIC_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 ``` All of these are public by design and safe in the client. Restart with a clean cache so Metro picks them up: bun run start -- -c **You should see:** `bun run doctor` green for the analytics and crash rows. ## 3. Decide who gets asked [#3-decide-who-gets-asked] The consent sheet asks users where the law requires opt-in and stays out of the way elsewhere: | Device | Before an answer | Sheet | | --------------------------------------------------------------------------------------------- | ---------------- | ------------------------------------------ | | Region in the EU/EEA, UK, Switzerland, Canada or Brazil, a `Europe/*` time zone, or no region | off | shown once, after onboarding | | Anywhere else | on | never; Settings → Privacy has the switches | | `privacy.askEverywhere: true` | off | shown to everyone | The region comes from the device's locale settings and time zone - no network lookup. To ask every user, whatever the region, flip one setting in `readynative.config.ts`: ```ts title="readynative.config.ts" privacy: { askEverywhere: true }, ``` Turn it on now even if you'll turn it off later - otherwise a simulator set to the US never shows you the sheet. **You should see:** on a fresh install (delete the dev build, or Expo Go's data, and open the app again), the **Your privacy** sheet after onboarding, with **Accept all**, **Save choices** and **Only necessary**. ## 4. Add a test card [#4-add-a-test-card] A temporary card with two buttons lets you trigger both SDKs by hand. Both calls work whatever options you picked: ```tsx title="src/components/telemetry-test.tsx" import { Box, Button, Card, Text } from "@/components/ui"; import { analytics } from "@/lib/analytics"; import { crash } from "@/lib/crash"; export function TelemetryTest() { return ( Telemetry test ); } ``` Render `` inside the `` of `src/screens/home/home-screen.tsx` for now. **You should see:** the card on Home. ## 5. Prove nothing is sent before consent [#5-prove-nothing-is-sent-before-consent] Open your live views side by side: Amplitude's **Analytics → User Look-Up**PostHog's **Activity → Live events**, and Sentry's **Issues**. 1. On the sheet, tap **Only necessary** (or, if you already answered, turn off **Analytics** and **Crash reports** in **Settings → Privacy**). 2. Tap **Send test event** and **Send test error** a few times. 3. Wait a minute. **You should see:** nothing in either dashboard - no event, no app-open, no issue. The SDKs aren't running. Now turn both switches on in **Settings → Privacy** (the change takes effect immediately) and tap the two buttons again. **You should see:** `telemetry_test` in the live view within seconds, and `Error: Telemetry test error` in Sentry within about a minute, tagged with the `dev` environment. Turn a switch off again and the matching SDK stops. Before release, set `askEverywhere` back to what you want and delete the test card. ## 6. Track what matters [#6-track-what-matters] Screen views are already tracked: the root layout calls `analytics.screen(pathname)` on every route change. Add your own events where something meaningful happens - for example, in the Notes screen from [Add your first feature](https://readynative.app/docs/first-feature). In `src/screens/notes/notes-screen.tsx`, wrap the store's `add` and pass the wrapper to the form: ```tsx title="src/screens/notes/notes-screen.tsx" import { analytics } from "@/lib/analytics"; // ...inside NotesScreen(), after `const { drafts, add } = useDrafts();`: const save = (title: string, body: string) => { add(title, body); analytics.track("note_saved", { length: body.length }); }; // ...and in the props: header={} ``` Keep properties free of personal data - counts, ids and choices, not emails or note text. Then tie events and errors to the signed-in user, so both dashboards can count affected people and account deletion can erase the right person on your server: ```ts title="src/hooks/use-identify.ts" import * as Sentry from "@sentry/react-native"; import { useEffect } from "react"; import { analytics } from "@/lib/analytics"; import { auth } from "@/lib/auth"; /** Tags analytics and crash reports with the signed-in user's id (never the email). */ export function useIdentify(): void { const session = auth.useSession(); const userId = session.status === "authenticated" ? session.user.id : null; useEffect(() => { if (!userId) return; analytics.identify(userId); Sentry.setUser({ id: userId }); }, [userId]); } ``` ```ts title="src/hooks/use-identify.ts" import { useEffect } from "react"; import { analytics } from "@/lib/analytics"; import { auth } from "@/lib/auth"; /** Tags analytics with the signed-in user's id (never the email). */ export function useIdentify(): void { const session = auth.useSession(); const userId = session.status === "authenticated" ? session.user.id : null; useEffect(() => { if (userId) analytics.identify(userId); }, [userId]); } ``` Call it once, in `RootStack` in `src/app/_layout.tsx`, next to the redirect hooks: ```tsx title="src/app/_layout.tsx" import { useIdentify } from "@/hooks/use-identify"; // ...inside RootStack(), after useSignOutCleanup(): useIdentify(); ``` Signing out already resets the identity (`wipeLocalData()` calls `analytics.reset()`). **You should see:** after signing in, your next events carry your user id in the dashboard. ## 7. Get readable stack traces [#7-get-readable-stack-traces] Sentry needs the source maps of each build and each update, or stack traces stay minified. This step uses EAS builds and `eas update`; if you haven't made either yet, come back to it after [Ship to TestFlight](https://readynative.app/docs/ship-to-testflight) and [Your first update](https://readynative.app/docs/your-first-update). Three build-time keys make the upload work - never with an `EXPO_PUBLIC_` prefix: * `SENTRY_ORG` and `SENTRY_PROJECT`: the slugs from your Sentry project URL. * `SENTRY_AUTH_TOKEN`: **Settings → Auth Tokens**, with the scopes `project:releases` and `org:read`. Put them in every EAS environment you build from, as `sensitive`: ```bash bunx eas-cli env:set --name SENTRY_ORG --value your-org --environment production --visibility sensitive bunx eas-cli env:set --name SENTRY_PROJECT --value your-project --environment production --visibility sensitive bunx eas-cli env:set --name SENTRY_AUTH_TOKEN --value sntrys_... --environment production --visibility sensitive ``` **EAS builds** then upload their source maps on their own, through the Sentry config plugin. **EAS updates** don't - after each `eas update`, upload the bundle it exported to `dist/`, with the same three keys set in your shell: ```bash bunx eas-cli update --channel production --environment production --message "Fix totals" bunx sentry-expo-upload-sourcemaps dist ``` **You should see:** in Sentry, an error from a release build or an update pointing at your TypeScript file and line, not at `index.bundle`. ## Check it [#check-it] bun run doctor bun run typecheck Every analytics, crash and consent row should be green, and typecheck should pass. ## If it doesn't work [#if-it-doesnt-work] * **Nothing arrives even after consent** - the key isn't set, or Metro still has the old env. Check `.env` and restart with `bun run start -- -c`. * **The sheet never shows** - the device's region isn't in the ask group and `askEverywhere` is off, or you already answered on this install. Set `askEverywhere: true` and reinstall. * **The first screen view of each launch is missing** - with `storage=async-storage` the consent record loads asynchronously, and events fired before it loads are dropped rather than sent without a known answer. `kv-store` and MMKV load it synchronously. * **Stack traces are minified** - the three `SENTRY_*` keys are missing from the EAS environment, or you didn't run `sentry-expo-upload-sourcemaps` after the update. * **Works locally, silent in a build** - the `EXPO_PUBLIC_*` keys aren't on EAS. See [A key works locally but not in an EAS build](https://readynative.app/docs/troubleshooting#a-key-works-locally-but-not-in-an-eas-build). More in [Troubleshooting](https://readynative.app/docs/troubleshooting). ## Congrats 🎉 [#congrats-] You can see how people use the app and where it breaks - with their consent, tied to their account, and with stack traces you can read. To take a module out again, see [Amplitude → Remove it](https://readynative.app/docs/features/analytics/amplitude#remove-it)[PostHog → Remove it](https://readynative.app/docs/features/analytics/posthog#remove-it) and [Sentry → Remove it](https://readynative.app/docs/features/crash/sentry#remove-it). Next, give the app its own backend in the cloud: [Deploy your API routes](https://readynative.app/docs/deploy-api-routes). # Add push notifications (/docs/add-push) Pro Hey - by the end of this page your phone shows a push notification you sent from your terminal, and tapping it opens the screen you chose - from the background and from a cold start. The module does the plumbing: `@/lib/push` asks for permission and fetches the Expo push token, `PushProvider` shows notifications while the app is open, creates the Android `default` channel and deep-links to `data.url` on tap, and `bun run push:test` sends one message through Expo's push service. You add the Apple and Google credentials and a place in your UI to ask for permission. ## Before you start [#before-you-start] * **Tier:** Pro. The push module isn't in Free or Starter. * **Time:** about 45 minutes, plus a dev build. * **Runs in:** a dev build on a **physical** phone. Expo Go can't receive remote push since SDK 53, and simulators never can - see [Expo Go or a dev build](https://readynative.app/docs/expo-go-vs-dev-build). * **Accounts:** the Apple Developer Program (iOS), a [Firebase](https://console.firebase.google.com) project (Android), and a free [Expo account](https://expo.dev/signup) - step 2 links the EAS project. * **Previous tutorial:** [Add a paywall](https://readynative.app/docs/add-a-paywall). **Picked `push=expo-notifications` 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 push starts from a fresh clone - see [Can I change a module after setup?](https://readynative.app/docs/faq#can-i-change-a-module-after-setup). ## 1. Add the module [#1-add-the-module] bun run setup --push expo-notifications --yes --keep-modules Push needs a dev build, so `setup` also switches `bun run start` to `expo start --dev-client`. **You should see:** `src/lib/push.ts`, `src/hooks/use-push-token.ts` and `scripts/push-test.ts` in your tree, and a `push:test` script in `package.json`. ## 2. Link the EAS project [#2-link-the-eas-project] The token request needs your EAS project id. If `app.easProjectId` in `readynative.config.ts` is still empty: ```bash bunx eas-cli login bunx eas-cli init ``` `eas init` only prints the id - paste it into `app.easProjectId`. With an empty id, the push hook reports `unsupported` and warns in the console. If the project belongs to an organisation, also set `app.owner` to its slug. bun run doctor **You should see:** "EAS project linked" green. ## 3. Add the iOS credentials [#3-add-the-ios-credentials] Apple delivers push through APNs, which needs a key from your developer account. EAS stores the key per bundle id: ```bash bunx eas-cli credentials --platform ios ``` Pick a build profile, then **Push Notifications**, and set up a key: sign in with your Apple account and let EAS create one, or upload a `.p8` you made in the Apple Developer portal → **Certificates, Identifiers & Profiles → Keys** with **Apple Push Notifications service (APNs)** ticked. Your dev build uses the `.dev` bundle id, so run the command for the `development` profile as well as `production`. **You should see:** the push key listed for your bundle id when you run the command again. ## 4. Add the Android credentials [#4-add-the-android-credentials] Android delivers push through Firebase Cloud Messaging (FCM v1). 1. In the [Firebase console](https://console.firebase.google.com), create a project and add an Android app for each package that will receive push - your production package from `readynative.config.ts`, and the same with `.dev` for your dev build. 2. Download `google-services.json` (it covers every Android app in the project) and put it at the repo root. It's configuration, not a secret, and EAS needs it in git to see it. 3. Tell the build about it - in `app.config.ts`, add one line to the `android` block: ```ts title="app.config.ts" android: { googleServicesFile: "./google-services.json", // ...the existing adaptiveIcon, package and other keys stay as they are }, ``` 4. In Firebase, open **Project settings → Service accounts → Generate new private key**. That JSON is what lets Expo's servers send through FCM - keep it out of git. 5. Upload it to EAS: ```bash bunx eas-cli credentials --platform android ``` Pick a profile, then **Google Service Account → Manage your Google Service Account Key for Push Notifications (FCM V1) → Set up → Upload a new service account key**. **You should see:** the FCM V1 key listed under your Android app's credentials on expo.dev. ## 5. Show the token in your app [#5-show-the-token-in-your-app] `push.usePushToken()` returns `{ token, status }` (`status` is `"loading"`, `"unsupported"`, `"denied"` or `"granted"`), and `push.requestPermission()` shows the OS prompt. Ask when it makes sense in your flow, not on first launch. Here's a card to drop into Settings or any screen while you test: ```tsx title="src/components/push-card.tsx" import * as Clipboard from "expo-clipboard"; import { Box, Button, Card, Text, toast } from "@/components/ui"; import { push } from "@/lib/push"; export function PushCard() { const { token, status } = push.usePushToken(); const allow = () => { push .requestPermission() .then((granted) => { if (!granted) toast.show({ title: "Notifications are off", kind: "error" }); }) .catch(() => toast.show({ title: "Couldn't ask for permission", kind: "error" })); }; const copy = (value: string) => { Clipboard.setStringAsync(value) .then(() => toast.show({ title: "Token copied", kind: "success" })) .catch(() => toast.show({ title: "Couldn't copy", kind: "error" })); }; return ( Push notifications Status: {status} {token ? {token} : null} {status !== "granted" ? : null} {token ? ( ) : null} ); } ``` For example, render `` inside the `` of `src/screens/home/home-screen.tsx`. `expo-clipboard` came with the module. **You should see:** `bun run typecheck` passing. The card shows up on the dev build in the next step. ## 6. Build and install on your phone [#6-build-and-install-on-your-phone] Credentials and `google-services.json` are native, so build now. On EAS: ```bash bunx eas-cli build --profile development --platform ios bunx eas-cli build --profile development --platform android ``` (iOS installs only on registered devices - `bunx eas-cli device:create` first.) Or locally with a phone plugged in: bunx expo run:ios --device Then start the dev server for it: bun run start:dev **You should see:** the push card with status `denied` (iOS reports "not asked yet" as `denied` too). Tap **Allow notifications**, accept the OS dialog, and an `ExponentPushToken[…]` appears. Tap **Copy token**. ## 7. Send yourself a push [#7-send-yourself-a-push] Background the app (or lock the phone), then from the repo root: bun run push:test "ExponentPushToken[paste-yours]" "Hello" "It works" --url /settings The arguments are the token, an optional title and body, and `--url`, the route the app opens on tap. Without `--url` it opens `/examples/push`, which exists only with `--with-examples`. It prints `Sent. Ticket …`. **You should see:** the notification within a few seconds. Tap it and the app opens on Settings. Kill the app, send again and tap: the same screen, from a cold start. With the app open, the banner still shows - that's the foreground handler. The ticket only means Expo accepted the message. Delivery errors such as `DeviceNotRegistered` or bad credentials show up in the push receipt, which you fetch with the ticket id - the link the script prints explains how. Or paste the token into [expo.dev/notifications](https://expo.dev/notifications) to send and inspect from the browser. ## Check it [#check-it] bun run doctor bun run typecheck Every push row should be green, and typecheck should pass. ## If it doesn't work [#if-it-doesnt-work] * **Status stays `unsupported`** - you're in Expo Go or a simulator, or `app.easProjectId` is empty. See [The push token is null](https://readynative.app/docs/troubleshooting#the-push-token-is-null). * **No token on Android** - `google-services.json` is missing, not referenced from `app.config.ts`, or has no app for the package you're running (dev builds use `.dev`). Fix it and rebuild. * **`Sent`, but nothing arrives** - check the push receipt for the ticket. `InvalidCredentials` means the APNs key or FCM V1 key isn't on EAS for this app; `DeviceNotRegistered` means the token is stale - reopen the app for a fresh one. * **Arrives, but the tap opens Home** - `--url` must be a route that exists, starting with `/`. * **Nothing on Android while the app is open for a long time** - check the app's notification settings on the phone: the `default` channel may be muted. More in [Troubleshooting](https://readynative.app/docs/troubleshooting). ## Congrats 🎉 [#congrats-] You sent a push from your terminal to your phone and landed on a screen from the tap. In production, store each user's token on your server and send with `data: { url }` from there. To take the module out again, see [Expo Notifications → Remove it](https://readynative.app/docs/features/push/expo-notifications#remove-it). Next, see how people use the app and where it breaks: [Add analytics and crash reporting](https://readynative.app/docs/add-analytics-and-crash). # Add sign-in (/docs/add-sign-in) Pro Hey - by the end of this page you'll sign up and sign in on the simulator, see your email in Settings, reset a forgotten password, and have signed-out users sent to the sign-in screen automatically. The screens are already written: `src/app/(auth)/sign-in`, `sign-up` and `reset` with `src/screens/auth/*` (Supabase adds `new-password`, where the reset link lands), plus `src/hooks/use-auth-redirect.ts`, which sends signed-out users to `/(auth)/sign-in` and signed-in users out of `(auth)` - onboarding still comes first. You add the keys and the dashboard settings. ## Before you start [#before-you-start] * **Tier:** Pro. The auth modules aren't in Free or Starter. * **Time:** about 45 minutes, most of it in the provider's dashboard. * **Runs in:** Expo Go, all three options. Sign in with Apple runs natively on iOS, and Google opens the system browser. * **Accounts:** a [Clerk](https://clerk.com) account.none - Better Auth runs on your own API routes (a Postgres database before you ship).a [Supabase](https://supabase.com) account. An Apple Developer account only for Sign in with Apple, and a Google Cloud project only for Google sign-in. * **Previous tutorial:** [Add your first feature](https://readynative.app/docs/first-feature). **Picked an auth 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 auth starts from a fresh clone - see [Can I change a module after setup?](https://readynative.app/docs/faq#can-i-change-a-module-after-setup). ## 1. Add the module [#1-add-the-module] bun run setup --auth clerk --yes --keep-modules bun run setup --auth better-auth --backend api-routes --yes --keep-modules Better Auth runs on your own server, so it requires `backend=api-routes`; setup refuses it with any other backend. bun run setup --auth supabase --yes --keep-modules Your stack toggle says `auth=none`, where `auth.useSession()` always reports signed-out. The steps below follow Supabase, the default; pick Clerk or Better Auth in the toggle above to switch them. Drop `--keep-modules` to let setup finalize the tree afterwards. **You should see:** setup finish with the auth module in its summary, and `src/lib/auth.ts` plus `src/app/(auth)/` in your tree. ## 2. Paste the keys [#2-paste-the-keys] Copy `.env.example` to `.env` if you haven't yet - `setup` lists every key your stack needs in it: ```bash cp .env.example .env ``` Create an application at [clerk.com](https://clerk.com), open **API keys**, and copy the publishable key: ```bash title=".env" EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... ``` Without it, `ClerkProvider` never mounts: `useSession()` stays `unauthenticated`, there's no redirect, and the sign-in screen shows "Configure Clerk". Better Auth needs the origin of your API routes, a server secret and its base URL. The two server keys have no `EXPO_PUBLIC_` prefix, which would ship them to every device. ```bash title=".env" EXPO_PUBLIC_API_URL=http://192.168.1.20:8081 BETTER_AUTH_SECRET=paste-the-output-of-openssl-here BETTER_AUTH_URL=http://192.168.1.20:8081 ``` Use your computer's LAN IP (a phone can't reach `localhost`), and generate the secret with: ```bash openssl rand -base64 32 ``` Without `EXPO_PUBLIC_API_URL`, the module is disabled and sign-in shows "Configure EXPO\_PUBLIC\_API\_URL". Create a project at [supabase.com](https://supabase.com), open **Project Settings → API** and copy two values: ```bash title=".env" EXPO_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJ... ``` Until both are set, auth is disabled: no redirect, `useSession()` stays `unauthenticated`, and the sign-in screen shows "Configure Supabase". Both keys are public - row-level security is what protects your data. Restart with a clean cache so Expo inlines the new keys: bun run start -- -c **You should see:** after onboarding, the app lands on the sign-in screen instead of Home. ## 3. Set up the dashboard [#3-set-up-the-dashboard] In the Clerk dashboard: 1. **User & Authentication → Email, phone, username**: enable **Email address** and **Password**, with verification set to **Email verification code**. The shipped sign-up and reset screens expect the code step. 2. **SSO connections → Apple**: enable it and add your iOS bundle id under "Apple → Native". Then turn on the Sign in with Apple capability for that bundle id in the Apple Developer portal → **Certificates, Identifiers & Profiles → Identifiers**. 3. **SSO connections → Google**: enable it. Development instances use Clerk's shared credentials, so there's nothing to paste yet. 4. **Native applications**: add the iOS bundle id and the Android package from `readynative.config.ts`, so Clerk accepts native requests from your app. Every screen in order is on the [Clerk page](https://readynative.app/docs/features/auth/clerk#setup). Better Auth has no dashboard - it's `src/server/auth.ts`. For this tutorial the shipped in-memory database is fine (every user is gone when the dev server restarts). Before you ship, swap it for Postgres and plug an email provider into `src/server/email.ts`; Apple and Google need their own client ids. The [Better Auth page](https://readynative.app/docs/features/auth/better-auth#setup) covers all of it. Check the server is up: ```bash curl -s http://localhost:8081/api/health ``` In the Supabase dashboard: 1. **Authentication → URL Configuration → Redirect URLs**: add `trailmix://` and `trailmix://new-password`, using the `scheme` from your `readynative.config.ts`. In Expo Go the links start with `exp://:8081/--/` instead, so also add `exp://**` - on your development project only. Without these, the Google and password-reset round trips never come back to the app. 2. **Authentication → Providers → Email**: leave "Confirm email" on. Sign-up then asks the user to confirm by email before the first sign-in. 3. **Authentication → Providers → Apple** (optional): enable it and set Client IDs to your iOS bundle id. The native flow uses `signInWithIdToken`, so no Services ID is needed. Turn on the Sign in with Apple capability for that bundle id in the Apple Developer portal → **Certificates, Identifiers & Profiles → Identifiers**. 4. **Authentication → Providers → Google** (optional): enable it with a **Web** OAuth client from Google Cloud Console → **APIs & Services → Credentials**, and paste Supabase's callback URL into that client's authorized redirect URIs. The app never needs a Google client id. bun run doctor **You should see:** every auth row green. ## 4. Sign up and sign in [#4-sign-up-and-sign-in] Open the app, tap **Sign up**, and create an account with an email you can read. Clerk emails a 6-digit code; enter it on the next screen and you're signed in. Better Auth signs you in straight away - the shipped setup sends no confirmation email. A toast says "Check your email". Open the email, confirm, then sign in with the same email and password. **You should see:** Home, and in **Settings** an **Account** card with your email (or your name with the email under it, when the provider has one), plus **Sign out** and **Delete account**. Kill the app and reopen it: you're still signed in. ## 5. Reset a password [#5-reset-a-password] On the sign-in screen, tap **Forgot password?** and enter your email. Clerk emails a code. Enter it, then choose the new password on the next screen. Reset emails go through `src/server/email.ts`, which logs a notice in dev until you plug in a provider (Resend, Postmark or your own) - see [Better Auth → Sending email](https://readynative.app/docs/features/auth/better-auth#sending-email). Come back to this step once it sends. The email's link opens the app on the **new password** screen (`/(auth)/new-password`). Enter the new password twice. **You should see:** "Password updated", and the new password works on the next sign-in. Open the same link again and it says "This link is invalid or has expired" - each link works once. ## 6. Use the session in your code [#6-use-the-session-in-your-code] `@/lib/auth` is the same API whichever option you picked, so this code survives a switch. Here's a small component that shows who's signed in - drop it into any screen, for example above the form in the Notes screen from [Add your first feature](https://readynative.app/docs/first-feature): ```tsx title="src/components/signed-in-as.tsx" import { Text } from "@/components/ui"; import { auth } from "@/lib/auth"; export function SignedInAs() { const session = auth.useSession(); if (session.status !== "authenticated") return null; return Signed in as {session.user.email ?? session.user.id}; } ``` `session.status` is `"loading"`, `"unauthenticated"` or `"authenticated"`, and only the last one has a `user` (`id`, and `email` / `name` when the provider has them). With `backend=api-routes`, call your own API as that user by attaching the credentials the server checks: ```ts title="src/lib/api/me.ts" import { auth } from "@/lib/auth"; import { env } from "@/lib/env"; export async function fetchMe(): Promise<{ id: string }> { const res = await fetch(`${env.API_URL}/api/me`, { headers: await auth.getAuthHeaders() }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return (await res.json()) as { id: string }; } ``` `/api/me` is yours to write: resolve the caller with `serverAuth.getRequestUser(request)` from `src/server/session.ts`, and never trust a user id sent by the app. With Clerk, that needs `CLERK_SECRET_KEY` (server, no `EXPO_PUBLIC_` prefix) in `.env`. [Deploy your API routes](https://readynative.app/docs/deploy-api-routes) covers the hosting side. **You should see:** "Signed in as [you@example.com](mailto:you@example.com)" where you placed ``. ## 7. Delete the account [#7-delete-the-account] The App Store and Google Play both require in-app account deletion, and Settings ships it. The anon key can't delete users, so the module ships two SQL migrations in `supabase/migrations/`: `delete_user()`, and the storage clean-up that runs before it. Apply them once per Supabase project. The quickest way is the dashboard: open **SQL Editor**, paste the contents of each file in order, and **Run**. Or use the Supabase CLI: ```bash bunx supabase login bunx supabase link --project-ref your-project-ref bunx supabase db push ``` The project ref is the `xxxx` in your `EXPO_PUBLIC_SUPABASE_URL`. Clerk deletes the user through `user.delete()`, so there's nothing to apply. With API routes, a `user.deleted` webhook can erase the user's analytics and purchase data too - see the [Clerk page](https://readynative.app/docs/features/auth/clerk). The shipped server enables `deleteUser`. Deleting needs a fresh session (signed in within the last day, set in `src/lib/auth-policy.ts`), otherwise the app asks for the password first. Now open **Settings → Delete account** and confirm. **You should see:** a success toast and the sign-in screen. Signing in with the old credentials now fails. ## Check it [#check-it] bun run doctor bun run typecheck Every auth row should be green, and typecheck should pass. ## If it doesn't work [#if-it-doesnt-work] * **The sign-in screen says "Configure …"** - a key is missing or Metro still has the old env. Check `.env`, then restart with `bun run start -- -c`. * **Google sign-in or the reset link never comes back to the app in Expo Go** - add `exp://**` to the Supabase redirect URLs; see [Supabase sign-in never comes back in Expo Go](https://readynative.app/docs/troubleshooting#supabase-sign-in-never-comes-back-in-expo-go). * **Delete account fails and names a migration file** - that migration isn't applied to this Supabase project yet. Run it (step 7) and try again; nothing was deleted. * **Sign in with Apple errors out** - the Sign in with Apple capability isn't on for the bundle id you're running. Dev builds use `.dev`, so register that one too. * **Works locally, signed out in a build** - `.env` isn't uploaded to EAS; set the keys there. See [A key works locally but not in an EAS build](https://readynative.app/docs/troubleshooting#a-key-works-locally-but-not-in-an-eas-build). More in [Troubleshooting](https://readynative.app/docs/troubleshooting). ## Congrats 🎉 [#congrats-] Your app has accounts: sign-up, sign-in, password reset and account deletion, with the session persisted through your storage adapter and the redirect hook guarding the app. To take the module out again, see [Clerk → Remove it](https://readynative.app/docs/features/auth/clerk#remove-it)[Better Auth → Remove it](https://readynative.app/docs/features/auth/better-auth#remove-it)[Supabase → Remove it](https://readynative.app/docs/features/auth/supabase#remove-it). Next, charge for it: [Add a paywall](https://readynative.app/docs/add-a-paywall). # Add web checkout next to the App Store (/docs/add-web-checkout) Pro Setup picks **one** payments option. This page is for when you want two: in-app purchase through RevenueCat (StoreKit / Play Billing, Apple's sheet, Face ID) **and** Stripe Checkout on the web (cards, Apple Pay and Google Pay on Stripe's page) - both unlocking the same `pro`. The finished version lives in `examples/snap-recipe`. Copy from there; this page explains the pieces and the rules. It assumes a tree set up with `payments=revenuecat`, `backend=api-routes` and an auth option, with RevenueCat working as in [Add a paywall](https://readynative.app/docs/add-a-paywall). The example was set up the other way round - `payments=stripe`, with RevenueCat added by hand as `examples/snap-recipe/src/lib/store-purchases.ts` - so its files split what your tree has in one place. In a RevenueCat tree, your `src/lib/payments.ts` is the RevenueCat rail: move it aside and take the example's `payments.ts` and `store-purchases.ts` together. Picked `payments=stripe`? You already have the Stripe rail; add `store-purchases.ts`, `react-native-purchases` and `react-native-purchases-ui`, and the example's paywall. Picked `payments=adapty`? The example has no Adapty rail: keep the Stripe files from the table, and write your own `store-purchases.ts` with the same exports on top of the Adapty calls in your current `src/lib/payments.ts` (Adapty's access levels play the part of RevenueCat's entitlements). | File in `examples/snap-recipe` | What it does | | ------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `examples/snap-recipe/src/lib/store-purchases.ts` | The RevenueCat rail: lazy configure, `logIn` as the signed-in user, native paywall, storefront | | `src/lib/payments.ts` | The Stripe rail + the payments contract: entitlements are the union of both rails | | `src/screens/paywall/paywall-screen.tsx` | App Store plans first, "Or pay by card through Stripe" below - only where allowed | | `src/app/api/stripe/*`, `src/server/stripe.ts` | Checkout session, entitlements lookup, webhook | ## 1. Start from RevenueCat, add the Stripe server [#1-start-from-revenuecat-add-the-stripe-server] Pick RevenueCat, API routes and an [auth](https://readynative.app/docs/add-sign-in) option when you run setup - Stripe needs a signed-in user: bun run setup --payments revenuecat --backend api-routes --auth supabase Then bring the server half of Stripe over by hand. `modules/` is gone after setup, but `examples/snap-recipe` carries the same files: copy `src/server/stripe.ts` and `src/app/api/stripe/` into your tree (they read the signed-in user through the `src/server/session.ts` your auth option wrote), add the package with `bunx expo install stripe`, and set `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID` and `STRIPE_WEBHOOK_SECRET` as on the [Stripe page](https://readynative.app/docs/features/payments/stripe#setup). ## 2. One user id on both rails [#2-one-user-id-on-both-rails] Both dashboards must know the buyer by the same id, or a Stripe subscription and an App Store subscription belong to two strangers: * **Stripe**: the checkout route stores `metadata.app_user_id = user.id` on the subscription and the entitlements route searches by it. * **RevenueCat**: call `Purchases.logIn(user.id)` when the session user changes and `Purchases.logOut()` on sign-out (`syncStoreUser()` in the example). A purchase made while signed out moves to the account on the next `logIn`. ## 3. Merge the entitlements [#3-merge-the-entitlements] `payments.useEntitlements()` returns the union, so every existing `active.includes("pro")` check keeps working: ```ts const store = useStoreEntitlements(); const active = useMemo( () => [...new Set([...stripeActive, ...store.active])].sort(), [stripeActive, store.active] ); ``` If your RevenueCat entitlement isn't literally `pro`, map it once (`REVENUECAT_PRO_ENTITLEMENT` in the example) instead of teaching every screen a second name. Make `restorePurchases()` do both: `Purchases.restorePurchases()` and a fresh Stripe lookup. ## 4. Only show Stripe where App Review allows it [#4-only-show-stripe-where-app-review-allows-it] Under App Review guideline 3.1.1, digital subscriptions sold inside an iOS app go through in-app purchase. A link-out to web checkout is allowed on the **US** storefront; elsewhere it can get the build rejected. Ask StoreKit which storefront the user is on: ```ts export function canOfferExternalPayments(): Promise { if (Platform.OS !== "ios" || !configureStore()) return Promise.resolve(true); return Purchases.getStorefront() .then((s) => ["US", "USA"].includes(s?.countryCode.toUpperCase() ?? "")) .catch(() => false); } ``` The paywall hides the Stripe card when this is `false`. Android and web always show it (check Google Play's payments policy for your markets). ## 5. Test both [#5-test-both] * **Store rail**: a RevenueCat Test Store key (`test_…`) shows RevenueCat's simulated purchase dialog; the `appl_…` key shows Apple's sheet for a sandbox Apple ID on a device. * **Stripe rail**: in test mode, the hosted page shows Apple Pay once you enable it under Stripe → Settings → Payment methods. The Apple Pay sheet itself needs a Wallet card, so use a real iPhone. ## Things to know [#things-to-know] * **Two subscriptions for one person.** Nothing stops someone who pays on the web from also buying in the store on another device. The paywall hides both once `pro` is active on the current one; if that isn't enough, check server-side before creating a checkout session. * **Refunds and cancellations** arrive through RevenueCat's customer-info listener and the Stripe webhook - both rails drop the entitlement on their own. * **Fees differ.** Apple and Google keep 15-30% of store sales; Stripe takes its card fee. Price the store product accordingly. * **The store sheet is not Apple Pay.** It charges the Apple ID's payment method (which may be an Apple Pay card). A native Apple Pay button is allowed only for physical goods and services, never for unlocking app features. # Get access (/docs/after-you-buy) Hey - here's what happens between paying and having the code. By the end you'll have your private repo cloned and know exactly what your tier gives you. On the Free tier there's nothing to buy: clone [`ready-native-free`](https://github.com/ReadyNative/ready-native-free) and go to [Quickstart](https://readynative.app/docs/ship-in-5-minutes). ## 1. Check out on Polar [#1-check-out-on-polar] [Polar](https://polar.sh) is the merchant of record: it takes the payment, charges the VAT or sales tax your country needs, and emails the receipt and invoice. Buying for a company? Add the company name and VAT number at checkout. ## 2. Connect GitHub in the Polar portal [#2-connect-github-in-the-polar-portal] Repository access is a GitHub benefit on your Polar order. Right after checkout, the thanks page has a **Connect GitHub to get the repo** button that opens Polar's customer portal for your order. Missed it? The receipt email from Polar links to the same portal. In the portal, connect the GitHub account that should get the repo. Each license gives one GitHub account access; on Pro, you share the code with the developers your license covers. ## 3. Accept the invite [#3-accept-the-invite] GitHub emails an invitation to the address on that GitHub account, and it also waits at [github.com/notifications](https://github.com/notifications). Accept it and you're a read-only collaborator on your tier's private repo. The invitation expires after 7 days. If it lapses, or you connected the wrong GitHub account, pick "Paid, no repo access" on [the support form](https://readynative.app/support/?topic=access) and leave the email you paid with - I'll sort it out. ## 4. Clone it [#4-clone-it] The repo is private, so git needs your GitHub credentials. Swap `pro` for `starter` if that's what you bought: ```bash git clone https://github.com/ReadyNative/ready-native-pro.git my-app cd my-app ``` `gh repo clone ReadyNative/ready-native-pro my-app` does the same through the GitHub CLI. The clone is yours to push anywhere: add your own remote and keep going. ## What's in each tier [#whats-in-each-tier] Here's how the three tiers compare. In a Starter or Pro tree, `.readynative-tier.json` at the root records the tier and the version you got. | Tier | Repo | Who may use it | What you get | | ------- | ------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Free | `ReadyNative/ready-native-free` (public) | Anyone (MIT) | The finished app with one stack already chosen: NativeWind 4, TanStack Query and Zustand, with tabs, settings and dark mode. No `setup`, `doctor` or `gen:*` scripts, no EAS or CI config, no agent files. Runs in Expo Go | | Starter | `ReadyNative/ready-native-starter` (invite) | One named individual | The picker: `bun run setup` chooses between every UI, data, state, storage, forms, i18n, onboarding, consent and testing option, with the `minimal` and `default` presets. Plus `doctor`, the `gen:*` generators, `eas.json`, Maestro flows and the agent files. No service modules | | Pro | `ReadyNative/ready-native-pro` (invite) | Up to 5 developers (employees or contractors) in one company | Everything in Starter, plus every service module (auth, payments, analytics, crash reporting, push, backend), the `saas` preset, the two [example apps](https://readynative.app/docs/examples) in `examples/`, the store-review agent skill and `bun run doctor --store` | Every paid license is perpetual with lifetime updates, and there are no refunds once you have access, except where the law requires otherwise. The terms are in [License](https://readynative.app/docs/license). ## Next [#next] Install, pick your stack and run the app: [Quickstart](https://readynative.app/docs/ship-in-5-minutes). # Do I need a backend? (/docs/backend) Hey - this is the page I'd read before picking anything in the `backend` category. By the end you'll know whether your app needs a server at all, how to point ReadyNative at one you already run, and which one to start with if you have none. A "backend" is everything that must not live on the phone. Secrets (a Stripe key, an API token) are readable by anyone who unzips your app, so they need a server. Data that two users share - a feed, a leaderboard, a team - needs a place both phones can reach. Payments have to be verified somewhere the buyer can't tamper with, and push notifications are *sent* by a server, not by the app. If your app is a calculator, a timer or a notes app that syncs nowhere, you don't need one and `bun run setup --backend none` is the right answer. The decision is short. **If you already have a backend, use it**: set `EXPO_PUBLIC_API_URL` and call it through `src/lib/api/client.ts` - the calls are in the [API routes usage](https://readynative.app/docs/features/backend/api-routes#usage) section. **If you don't, start with the built-in one**: Expo API routes in the same tree, deployed to EAS Hosting. ## You already have a backend [#you-already-have-a-backend] Then ReadyNative is a client, and all it needs is the origin. Put it in `.env`: ```bash title=".env" EXPO_PUBLIC_API_URL=https://api.example.com ``` `setup` lists this key in `.env.example` whenever a data, auth or backend module wants it, and `src/lib/env.ts` exposes it as `env.API_URL` after zod validation. Relative paths passed to `fetchJson` resolve against it; absolute URLs pass through: ```ts import { fetchJson } from "@/lib/api/client"; const me = await fetchJson<{ id: string; email: string }>("/v1/me"); ``` Non-2xx responses reject with an `ApiError` carrying `status` and the parsed body, and every call times out after 15 seconds unless you pass `timeoutMs`. To authenticate, add the header yourself. `auth.useSession()` from `@/lib/auth` tells you *whether* you're signed in under every auth option; the token itself comes from the vendor client, because each vendor issues a different kind. With Supabase: ```ts import { supabase } from "@/lib/auth"; import { fetchJson } from "@/lib/api/client"; const { data } = await supabase!.auth.getSession(); const token = data.session?.access_token; await fetchJson("/v1/me", { headers: token ? { authorization: `Bearer ${token}` } : {} }); ``` Clerk gives you a JWT through `useAuth().getToken()`, and Better Auth sends a cookie, so its routes have to live on the same origin as `EXPO_PUBLIC_API_URL`. Each option's page under [Auth](https://readynative.app/docs/features/auth) shows the call. Two more things. Native apps don't send an `Origin` header, so CORS never blocks them - but the web build does, so if you ship to the web, allow your app's web origin (and `http://localhost:8081` in dev) on the server. And never put a secret behind an `EXPO_PUBLIC_` prefix: those keys are inlined into the JavaScript bundle and anyone can read them. Your backend holds the secrets; the app holds only the URL. ## You don't have one yet [#you-dont-have-one-yet] Here's the ladder I'd climb. Each rung is a complete answer; most apps never leave the first one. ### Expo API routes on EAS Hosting [#expo-api-routes-on-eas-hosting] Pro This is the `backend/api-routes` module, the default backend in the Pro `saas` preset; it and the `src/server/*` helpers below only exist in a Pro tree set up with `backend=api-routes`. A file named `+api.ts` under `src/app/api/` exports `GET`, `POST` and friends, runs on the dev server while `expo start` is up, and runs on EAS Hosting (Cloudflare Workers under the hood) once deployed. Both `auth/better-auth` and `payments/stripe` require it. * **Good for**: a handful of endpoints, webhooks, hiding a third-party key, the first version of almost anything. * **What you write**: `+api.ts` handlers with the shipped `src/server/json.ts` helpers (`handle`, `json`, `readJson` with zod) and `serverEnv()` for secrets. * **Where it runs**: your dev machine in development, EAS Hosting in production, same repo. * **Free tier / cost shape**: the free Expo plan includes 100,000 requests a month and 1 GB of storage; custom domains need a paid plan. * **When you outgrow it**: you need a database (pair it with Supabase or a hosted Postgres), long-running jobs, websockets, or a Node module the Workers runtime can't load. First deploy: pick it at setup (`bun run setup --backend api-routes`), then follow [Deploy your API routes](https://readynative.app/docs/deploy-api-routes) - the local health check, server keys on EAS, `eas deploy`, and pointing your builds at the hosted URL through an EAS environment. ### Supabase [#supabase] Pro Postgres with a REST API, auth, file storage, realtime and edge functions, all behind one dashboard. It pairs with `auth/supabase`, so if you picked that auth option you already have a backend. * **Good for**: shared data with row-level security, user accounts and files without writing a server. * **What you write**: SQL (tables and policies) and `supabase.from("…")` calls from the app; Deno edge functions when you need server code. * **Where it runs**: Supabase's cloud, in the region you pick. * **Free tier / cost shape**: two active projects, a 500 MB database, 50,000 monthly active users, 1 GB of file storage and 500,000 edge function invocations; free projects pause after a week of inactivity. * **When you outgrow it**: you want business logic in TypeScript rather than SQL policies, or a runtime other than Deno for server code. First deploy: 1. Pick it at setup: bun run setup --auth supabase Already finalized without it? Re-clone and re-run setup ([FAQ](https://readynative.app/docs/faq#can-i-change-a-module-after-setup)). 2. Create a project at supabase.com and paste the Project URL and anon key into `.env` as `EXPO_PUBLIC_SUPABASE_URL` and `EXPO_PUBLIC_SUPABASE_ANON_KEY`. 3. Create your first table in the SQL editor and turn on row-level security. 4. Query it with the `supabase` client exported from `@/lib/auth` - there's nothing to deploy. ### Convex [#convex] A reactive database and serverless functions in one, TypeScript end to end: queries you subscribe to re-run in the app whenever the data changes. * **Good for**: collaborative and live-updating apps, or when you'd rather never think about caching and invalidation. * **What you write**: `query`, `mutation` and `action` functions in a `convex/` folder, called through `useQuery` and `useMutation` in components. * **Where it runs**: Convex's cloud; `npx convex dev` syncs your functions as you save. * **Free tier / cost shape**: 1 million function calls, 0.5 GB of database storage and 1 GB of file storage included, then pay-as-you-go. * **When you outgrow it**: you need raw SQL, a specific database engine, or to run on your own infrastructure. First deploy: 1. bunx expo install convex 2. bunx convex dev creates the `convex/` folder and writes `EXPO_PUBLIC_CONVEX_URL` to `.env.local`. 3. Wrap the app in `ConvexProvider` in `src/providers.tsx` with `new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!)`. 4. Add a `query` in `convex/tasks.ts` and read it with `useQuery(api.tasks.get)`. 5. bunx convex deploy pushes to production when you're ready. ### A small server you own [#a-small-server-you-own] Hono or Express on Railway, Fly.io, Cloudflare Workers or Vercel Functions. Pick this when the API is the product - many endpoints, background jobs, websockets, a queue, a database you administer - or when your team already runs one of these hosts. * **Good for**: anything the rungs above can't run, and teams that want full control. * **What you write**: a normal HTTP server, plus a `Dockerfile` or a host config file. * **Where it runs**: Railway and Fly.io run containers (long-lived processes, websockets, cron); Cloudflare Workers and Vercel Functions run request handlers that scale to zero. * **Free tier / cost shape**: Cloudflare Workers gives 100,000 requests a day free and $5/month after; Vercel's Hobby plan includes 1 million invocations a month for non-commercial use; Railway offers a $5 one-time trial credit and a $5/month Hobby plan; Fly.io's trial is 2 machine-hours or 7 days, then usage billing. * **When you outgrow it**: you don't - this is the top of the ladder. What changes is how much of it you operate yourself. First deploy (Hono on Cloudflare Workers, the cheapest of the four to keep running): 1. bun create hono\@latest my-api and pick the `cloudflare-workers` template. 2. Add a route: `app.get("/health", (c) => c.json({ ok: true }))`. 3. bunx wrangler dev serves it at `http://localhost:8787`. 4. bunx wrangler deploy gives you `https://..workers.dev`. 5. Set `EXPO_PUBLIC_API_URL` to that origin and store secrets with `wrangler secret put `. Side by side, the four rungs compare like this: | Option | You write | Runs on | Database included? | Auth included? | Free tier | | --------------- | -------------------- | -------------------------------- | -------------------- | ------------------------ | --------------------- | | Expo API routes | `+api.ts` handlers | EAS Hosting | no | with `auth/better-auth` | 100k requests/month | | Supabase | SQL + client calls | Supabase cloud | Postgres | yes (`auth/supabase`) | 2 projects, 500 MB DB | | Convex | TypeScript functions | Convex cloud | reactive document DB | via Convex Auth or Clerk | 1M calls/month | | Your own server | Hono / Express app | Railway, Fly.io, Workers, Vercel | bring your own | bring your own | varies by host | ## Things every backend needs [#things-every-backend-needs] Whichever rung you're on, the same short list keeps you out of trouble: * **Secrets live on the server.** Locally they go in `.env` under the `# server (never EXPO_PUBLIC)` heading; in production they go in the host's secret store (`eas env:set --visibility sensitive` for EAS Hosting, `wrangler secret put`, the Railway or Vercel dashboard). Read them through `serverEnv()` so a missing key fails at startup, not in a request. * **A `/health` route.** The module ships `GET /api/health`; keep one on any server so you, `curl` and your uptime monitor can tell "deployed" from "working". * **CORS for the web build.** Native ignores it; the browser doesn't. Allow your web origin and `http://localhost:8081` explicitly rather than `*` once auth cookies are involved. * **A version in the path.** `/v1/…` costs nothing today and lets old app builds keep working when you change a response shape - phones don't update the day you deploy. * **Logging you can read.** If you picked `crash/sentry`, add the Sentry server SDK to the same project so a failed request and the crash it caused show up together. See [Crash reporting](https://readynative.app/docs/features/crash). * **Rate limiting.** Any route a signed-out user can hit (sign-in, password reset, a webhook) needs a per-IP limit - EAS Hosting, Workers and Vercel all have one you can turn on. A `/health` route, one real endpoint, and the secret it needs - then point a dev build at the hosted URL and tap through the flow on a phone before you write the second endpoint. ## Where the keys go [#where-the-keys-go] Every option reduces to a few lines in `.env`. `bun run doctor` checks the ones a module marks as required and prints the dashboard and guide links for each missing one - the rows are explained in [Doctor](https://readynative.app/docs/doctor). | Option | Client keys (`EXPO_PUBLIC_`) | Server keys | `doctor` checks | | --------------- | ----------------------------------------------------------- | --------------------------------------------------------------- | --------------------- | | Expo API routes | `EXPO_PUBLIC_API_URL` | `WEBHOOK_SECRET` | `EXPO_PUBLIC_API_URL` | | + Better Auth | `EXPO_PUBLIC_API_URL` | `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DATABASE_URL` | the first three | | + Stripe | `EXPO_PUBLIC_API_URL`, `EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY` | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ID` | all five | | Supabase | `EXPO_PUBLIC_SUPABASE_URL`, `EXPO_PUBLIC_SUPABASE_ANON_KEY` | none in the app | both | | Convex | `EXPO_PUBLIC_CONVEX_URL` | set in the Convex dashboard | not a module; nothing | | Your own server | `EXPO_PUBLIC_API_URL` | whatever the server reads | nothing | The anon key and the publishable key are safe in the client by design; a secret key, a webhook signing secret or a database URL never is. ## Next [#next] * [API routes](https://readynative.app/docs/features/backend/api-routes) - the module in detail, with the helpers and the example route. * [Deploy your API routes](https://readynative.app/docs/deploy-api-routes) - the tutorial that puts them on EAS Hosting. * [Auth](https://readynative.app/docs/features/auth) - Supabase, Clerk or Better Auth on top of whichever backend you chose. {/* Research notes (2026-09-15): docs.expo.dev api-routes, eas/hosting/introduction and get-started, expo.dev/pricing, supabase.com pricing, convex.dev pricing and RN quickstart, hono.dev, Cloudflare Workers guide and pricing, vercel.com/docs/functions and plans/hobby, railway.com pricing and quick-start, fly.io free-trial all fetched OK. docs.railway.com/quick-start and fly.io/docs/getting-started carried no pricing, so those numbers come from the pricing pages. EAS Hosting rate limiting is stated generically; the docs pages fetched don't describe a specific feature. */} # Changelog (/docs/changelog) {/* Generated by apps/docs/scripts/gen-modules.ts from CHANGELOG.md - do not edit. */} All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions are tagged `v-sdk`, the Expo SDK the release targets. ## \[1.3.0-sdk57] - 2026-09-28 [#130-sdk57---2026-09-28] ### Added [#added] * A weather demo is the app you see on first run (`bun install && bun ios`): the forecast where you are, an hourly strip, a 10-day list, detail tiles, city search and saved cities, °C / °F, pull to refresh, share and "notify me", a Pro gate on the 10-day forecast. It runs on every module choice and keylessly on [Open-Meteo](https://open-meteo.com); `setup` removes it unless you pass `--with-examples`. * Native navigation throughout: the tab bar is `NativeTabs` (Liquid Glass on iOS 26, Material 3 on Android), and the demo's Cities tab uses a large-title native stack, `Stack.SearchBar` and `Link.Menu` context menus. `bun run gen screen --tab` writes a ``. * `location` category: `expo-location` (foreground position and reverse geocoding behind `@/lib/location`, resolving `null` instead of throwing) or `none`. In the `default` and `saas` presets. * `widgets` category: `expo-widgets` (an iOS home-screen widget in `@expo/ui` SwiftUI views, fed by `widgets.update()`) or `none`. In the `saas` preset; needs a dev build. * Six languages in both i18n modules - English, Spanish, Russian, Chinese (Simplified), Portuguese and Arabic - with right-to-left layout: picking Arabic mirrors the app and restarts it. Settings lists languages as rows. * `@/lib/cache`: an offline cache on the storage adapter; the last good response paints on the first frame and without a connection. * Native feel in every UI stack: `toast.show()` is a native iOS toast (Burnt) in dev and store builds, with the themed card as the Expo Go / Android / web fallback; `Pressable` runs on Pressto (UI-thread press animation on a gesture-handler button); `` is keyboard-controller's `KeyboardAwareScrollView`. The root layout mounts `GestureHandlerRootView` and `KeyboardProvider`. * `Sheet` primitive in the UI contract: `@expo/ui`'s native bottom sheet (SwiftUI on iOS, Material 3 on Android, a drawer on web), with detents. Works in Expo Go. * Live Activities: `widgets.activity.start / update / end` with a Lock Screen + Dynamic Island layout in `src/widgets/app-activity.tsx` (widgets/expo-widgets). * Docs: [No ios/ or android/ folders](https://readynative.app/docs/native-without-native-folders) - how Continuous Native Generation and over-the-air updates fit together. ### Changed [#changed] * The Explore tab is gone; the weather demo's Cities tab replaces it. * The onboarding Maestro flow ends on the tab bar instead of the Home title. ## \[1.2.1-sdk57] - 2026-09-27 [#121-sdk57---2026-09-27] ### Removed [#removed] * The GitHub Actions workflows (`.github/workflows/ci.yml`, `eas-preview.yml`): the repo no longer runs CI or EAS builds on GitHub. Build and submit with `eas build` / `eas submit`; add your own CI if you want one ([Expo's guide](https://docs.expo.dev/eas-update/github-actions/)). `setup` still re-renders a `ci.yml` you kept from 1.2.0 for your package manager. ### Fixed [#fixed] * `bun run typecheck` on a fresh clone: Expo's global types are now referenced from the committed `expo-types.d.ts` (the generated `expo-env.d.ts` is gitignored and only appears after `expo start`). * `bun run test` before setup: the UI stack's jest mocks (the `@/global.css` stub) load without `.readynative.json`. * `docs/graph.json` / `docs/ARCHITECTURE.md` now match the tree you receive, so `bun run gen:graph --check` passes out of the box. * README links point to the docs at [https://readynative.app/docs/](https://readynative.app/docs/). ## \[1.2.0-sdk57] - 2026-09-25 [#120-sdk57---2026-09-25] First public release, on Expo SDK 57 (Expo 57.0.24, React Native 0.86, Expo Router 57, React 19.2). ### Added [#added-1] * Module system: every optional feature lives in `modules//