# 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.
Unlock Pro
);
}
```
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
analytics.track("telemetry_test", { source: "home" })}>
Send test event
crash.capture(new Error("Telemetry test error"), { source: "home" })}
>
Send test error
);
}
```
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" ? Allow notifications : null}
{token ? (
copy(token)}>
Copy 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///`; `bun run setup` applies a preset (`minimal`, `default`, `saas`) or a per-category selection, generates `src/providers.tsx`, `src/lib/env.ts`, `.env.example` and `.readynative.json`, and stays idempotent. Setup then finalizes the tree into a plain Expo Router app (`modules/` and the setup tooling removed, `src/ui` → `src/components/ui`); `--keep-modules` keeps the module system.
* Five UI stacks behind one component contract: NativeWind 4 (the default: stable `4.2.7` on Tailwind 3, in every preset), NativeWind 5 (RC · Tailwind 4, `5.0.0-rc.0`, one `--ui nativewind5` away), Tamagui, Unistyles and plain StyleSheet, with `bun run gen:theme` deriving each stack's theme from `src/theme/tokens.ts` and the brand in `readynative.config.ts`.
* App-layer modules: data (TanStack Query, Apollo, SWR), state (Zustand, Jotai), storage (expo-sqlite/kv-store, MMKV, AsyncStorage), forms (react-hook-form), i18n (i18next, Lingui; English and Spanish, with a language picker in Settings), onboarding flow.
* Service modules (Pro), each with a no-op shim for its `none` option: auth (Supabase, Clerk, Better Auth), payments (RevenueCat, Stripe, Adapty), analytics (PostHog, Amplitude), crash (Sentry), push (expo-notifications), backend (API routes).
* Account deletion on every auth module (Supabase via a shipped `delete_user()` migration, Clerk `user.delete()`, Better Auth `deleteUser`) and a confirm-guarded "Delete account" row in Settings.
* Payments: `presentPaywall()` shows RevenueCat's native paywall only to users without the entitlement and falls back to `/paywall` (auto-renewal disclosure, "Compare all plans", "Manage subscription"); RevenueCat follows the signed-in user. A guide covers web checkout next to the App Store: RevenueCat and Stripe Checkout with one `pro` entitlement across both rails (`examples/snap-recipe` is the reference).
* Feature flags on the analytics contract: `analytics.isFeatureEnabled(key)`, `analytics.getFeatureFlag(key)` and `useFeatureFlag(key)` (PostHog; Amplitude and `none` read `undefined`).
* App Tracking Transparency with the analytics modules: `expo-tracking-transparency`, `src/lib/tracking.ts`, and a one-time prompt when `features.tracking` is on in `readynative.config.ts`.
* Privacy and consent: a `consent` category whose default asks once (EU/EEA, UK, Switzerland, Canada, Brazil, or no device region) with Analytics / Crash reports toggles. PostHog, Amplitude and Sentry follow the choice, and Sentry events are scrubbed of IP, email, username, headers and cookies. Settings → Privacy adds "Do not sell or share my personal information" and "Export my data"; sign-out and account deletion wipe local data.
* Store paperwork from the modules: every module declares its privacy data. Setup composes the Apple privacy manifest (`ios.privacyManifests`) and the Play Data safety rows, and `bun run gen:privacy` writes a privacy-policy draft to `docs/privacy-policy.md`.
* `bun run doctor`: env keys (with a dashboard link and a docs guide for each missing one), native toolchains and runtime versions in one checklist; store identity (placeholder ids, urls, store ids, EAS link and login, the placeholder app icon) is listed under "Before you ship" as warnings, so a fresh setup exits 0. `bun run doctor --store` (Pro) checks the app against the App Store Review Guidelines and Google Play policies, with a guideline id and a fix per finding.
* Package manager of your choice: setup detects bun / pnpm / yarn / npm (or `--pm`), installs with it, drops the other lockfiles and renders CI for it. `scripts/*.ts` run under plain Node 22.18+ (`setup` and `doctor` check it first); bun is the recommended default.
* First run in Expo Go: `start` is `expo start --go` when every selected module runs there and `expo start --dev-client` otherwise; `start:dev` always targets the dev build. Module tools such as `push:test` survive setup and run under any package manager.
* `bun run gen:graph` writes `docs/graph.json` + `docs/ARCHITECTURE.md`, the app as one graph (modules, providers, routes, screens, libs, env keys); CI checks it stays current.
* Agent skills in `.agents/skills/` (`readynative`, `expo-router`, `expo-native-ui`, `expo-data-fetching`; Pro adds `store-review`).
* `examples/` (Pro): Field Notes and Snap Recipe, standalone apps that boot with no `.env` (`cd examples/notes && bun install && bun ios`).
* Always-on extras: icon / splash / favicon from `assets/brand/icon.png` (`bun run gen:assets`), universal and app links (`bun run gen:links`), updates banner, store review prompt, clipboard / share / browser helpers, root error boundary, and the iOS 27 UIScene life cycle (`plugins/with-scene-lifecycle.js`).
* CI and EAS workflows: typecheck, lint, test and the architecture graph on every push; EAS preview builds on pull requests and production build + submit on your own `v*` tags.
* Every preset and every module option is set up, typechecked, linted, bundled and tested in CI before a release ships.
* Free tier: `readynative-free`, a public MIT repo with one fixed stack (Expo Router, NativeWind 4, TanStack Query, Zustand, `expo-sqlite/kv-store`), already set up: no `modules/`, no setup, no EAS / CI / scripts.
* Licensing: Starter and Pro ship under the ReadyNative Commercial License, the Free tier under MIT; third-party notices (the Expo template's MIT text) are in `LICENSES/`.
# Stack comparison (/docs/choose-your-stack)
{/* Generated by apps/docs/scripts/gen-modules.ts from every modules///module.json - do not edit. */}
Every option of every category side by side. `bun run setup` asks one question per category; every answer is a module under `modules///`. Non-interactive: `bun run setup --preset default --ui tamagui --auth supabase --yes`. This page is generated from the same `module.json` files `setup` reads, so it can't drift from what ships. For how to choose, read [Pick your stack](https://readynative.app/docs/pick-your-stack) first.
## Presets [#presets]
| Category | `minimal` | `default` | `saas` |
| ------------ | ------------- | ----------------- | -------------------- |
| `ui` | `nativewind4` | `nativewind4` | `nativewind4` |
| `data` | `none` | `react-query` | `react-query` |
| `state` | `none` | `zustand` | `zustand` |
| `storage` | `kv-store` | `kv-store` | `kv-store` |
| `forms` | `none` | `react-hook-form` | `react-hook-form` |
| `i18n` | `none` | `i18next` | `i18next` |
| `auth` | `none` | `none` | `supabase` |
| `payments` | `none` | `none` | `revenuecat` |
| `analytics` | `none` | `none` | `posthog` |
| `crash` | `none` | `none` | `sentry` |
| `push` | `none` | `none` | `expo-notifications` |
| `backend` | `none` | `none` | `api-routes` |
| `onboarding` | `off` | `on` | `on` |
| `location` | `none` | `expo-location` | `expo-location` |
| `widgets` | `none` | `none` | `expo-widgets` |
| `consent` | `none` | `none` | `consent` |
| `testing` | `none` | `jest` | `jest` |
The committed tree equals `setup --preset default`; `saas` is `default` plus every service category turned on.
## UI (`ui`) [#ui-ui]
| Option | Label | Expo Go | Hint |
| ---------------------------------------------- | ---------------------- | ----------------------- | ------------------------------------------------------------------------------------ |
| [`nativewind4`](https://readynative.app/docs/features/ui/nativewind4) | NativeWind 4 (default) | yes | Stable · Tailwind 3 classes + RNR components behind @/ui · Expo Go OK |
| [`nativewind5`](https://readynative.app/docs/features/ui/nativewind5) | NativeWind 5 | yes | RC · Tailwind 4 classes behind @/ui · Expo Go OK |
| [`tamagui`](https://readynative.app/docs/features/ui/tamagui) | Tamagui | yes | Tamagui 2 (v5 config, optimising compiler) behind @/ui · Expo Go OK |
| [`unistyles`](https://readynative.app/docs/features/ui/unistyles) | Unistyles 3 | no - dev build required | C++ StyleSheet with themes/breakpoints behind @/ui · dev build required (no Expo Go) |
| [`stylesheet`](https://readynative.app/docs/features/ui/stylesheet) | StyleSheet (plain RN) | yes | no style engine, tokens + StyleSheet.create · Expo Go OK |
## Data (`data`) [#data-data]
| Option | Label | Expo Go | Hint |
| ------------------------------------------------ | ------------------------------------------------ | ------- | ------------------------------------------------ |
| [`react-query`](https://readynative.app/docs/features/data/react-query) | TanStack Query (default) (most people pick this) | yes | server state + cache · Expo Go OK |
| [`apollo`](https://readynative.app/docs/features/data/apollo) | Apollo Client | yes | GraphQL client + normalized cache · Expo Go OK |
| [`swr`](https://readynative.app/docs/features/data/swr) | SWR | yes | stale-while-revalidate hooks · Expo Go OK |
| [`none`](https://readynative.app/docs/features/data/none) | No data layer | yes | plain fetch; add TanStack Query/Apollo/SWR later |
## State (`state`) [#state-state]
| Option | Label | Expo Go | Hint |
| ----------------------------------------- | ----------------------------------------- | ------- | ----------------------------------------------------------- |
| [`zustand`](https://readynative.app/docs/features/state/zustand) | Zustand (default) (most people pick this) | yes | tiny stores, persisted on the storage adapter · Expo Go OK |
| [`jotai`](https://readynative.app/docs/features/state/jotai) | Jotai | yes | atomic state, persisted on the storage adapter · Expo Go OK |
| [`none`](https://readynative.app/docs/features/state/none) | No state library | yes | built-in useSyncExternalStore stores only |
* [`zustand`](https://readynative.app/docs/features/state/zustand): requires `storage` ∈ `kv-store` | `mmkv` | `async-storage`
* [`jotai`](https://readynative.app/docs/features/state/jotai): requires `storage` ∈ `kv-store` | `mmkv` | `async-storage`
## Storage (`storage`) [#storage-storage]
| Option | Label | Expo Go | Hint |
| ------------------------------------------------------- | ------------------------------------------------------ | ----------------------- | ---------------------------------------------------------------- |
| [`kv-store`](https://readynative.app/docs/features/storage/kv-store) | expo-sqlite/kv-store (default) (most people pick this) | yes | sync + async key/value on SQLite · Expo Go OK |
| [`mmkv`](https://readynative.app/docs/features/storage/mmkv) | MMKV | no - dev build required | fastest sync key/value (Nitro) · dev build required (no Expo Go) |
| [`async-storage`](https://readynative.app/docs/features/storage/async-storage) | AsyncStorage | yes | async-only key/value, the community standard · Expo Go OK |
## Forms (`forms`) [#forms-forms]
| Option | Label | Expo Go | Hint |
| --------------------------------------------------------- | ------------------------------------------------- | ------- | ----------------------------------------------------- |
| [`react-hook-form`](https://readynative.app/docs/features/forms/react-hook-form) | React Hook Form (default) (most people pick this) | yes | Form + Field on @/ui Input, zod resolver · Expo Go OK |
| [`none`](https://readynative.app/docs/features/forms/none) | No forms library | yes | controlled @/ui Inputs with useState |
## i18n (`i18n`) [#i18n-i18n]
| Option | Label | Expo Go | Hint |
| ---------------------------------------- | ----------------------------------------- | ------- | --------------------------------------------------------------------------------------------- |
| [`i18next`](https://readynative.app/docs/features/i18n/i18next) | i18next (default) (most people pick this) | yes | react-i18next + expo-localization, 6 languages bundled incl. Arabic (RTL) · Expo Go OK |
| [`lingui`](https://readynative.app/docs/features/i18n/lingui) | Lingui | yes | @lingui/core + @lingui/react runtime API, 6 languages bundled incl. Arabic (RTL) · Expo Go OK |
| [`none`](https://readynative.app/docs/features/i18n/none) | No i18n | yes | i18n.t/useT return the English key |
* [`i18next`](https://readynative.app/docs/features/i18n/i18next): requires `storage` ∈ `kv-store` | `mmkv` | `async-storage`
* [`lingui`](https://readynative.app/docs/features/i18n/lingui): requires `storage` ∈ `kv-store` | `mmkv` | `async-storage`
## Auth (`auth`) [#auth-auth]
| Option | Label | Expo Go | Hint |
| ------------------------------------------------ | ----------------------------------------------- | ------- | --------------------------------------------------------------------------------------------- |
| [`supabase`](https://readynative.app/docs/features/auth/supabase) | Supabase Auth (default) (most people pick this) | yes | email+password, Apple (native), Google (OAuth) · session on the storage adapter · Expo Go OK |
| [`clerk`](https://readynative.app/docs/features/auth/clerk) | Clerk | yes | email+code verification, Apple (native), Google (SSO) · @clerk/expo · Expo Go OK for JS flows |
| [`better-auth`](https://readynative.app/docs/features/auth/better-auth) | Better Auth | yes | self-hosted on the API routes · email/password + Apple/Google · Expo Go OK |
| [`none`](https://readynative.app/docs/features/auth/none) | No auth | yes | auth.useSession() shim returns signed-out, signOut resolves |
* [`supabase`](https://readynative.app/docs/features/auth/supabase): requires `storage` ∈ `kv-store` | `mmkv` | `async-storage`; needs `EXPO_PUBLIC_SUPABASE_URL`, `EXPO_PUBLIC_SUPABASE_ANON_KEY`
* [`clerk`](https://readynative.app/docs/features/auth/clerk): requires `storage` ∈ `kv-store` | `mmkv` | `async-storage`; needs `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`
* [`better-auth`](https://readynative.app/docs/features/auth/better-auth): requires `backend` ∈ `api-routes`; needs `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`
## Payments (`payments`) [#payments-payments]
| Option | Label | Expo Go | Hint |
| -------------------------------------------------- | -------------------------------------------- | ----------------------- | ------------------------------------------------------------------------ |
| [`revenuecat`](https://readynative.app/docs/features/payments/revenuecat) | RevenueCat (default) (most people pick this) | no - dev build required | in-app subscriptions + paywall · dev build required (no Expo Go) |
| [`adapty`](https://readynative.app/docs/features/payments/adapty) | Adapty | no - dev build required | in-app subscriptions + Paywall Builder · dev build required (no Expo Go) |
| [`stripe`](https://readynative.app/docs/features/payments/stripe) | Stripe Checkout | yes | web checkout via API routes (no native SDK) · Expo Go OK |
| [`none`](https://readynative.app/docs/features/payments/none) | No payments | yes | payments.useEntitlements() shim returns none |
* [`revenuecat`](https://readynative.app/docs/features/payments/revenuecat): needs `EXPO_PUBLIC_REVENUECAT_IOS_KEY`, `EXPO_PUBLIC_REVENUECAT_ANDROID_KEY`
* [`adapty`](https://readynative.app/docs/features/payments/adapty): needs `EXPO_PUBLIC_ADAPTY_PUBLIC_KEY`
* [`stripe`](https://readynative.app/docs/features/payments/stripe): requires `backend` ∈ `api-routes`; needs `EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ID`
## Analytics (`analytics`) [#analytics-analytics]
| Option | Label | Expo Go | Hint |
| ------------------------------------------------- | ----------------------------------------- | ------- | -------------------------------------------------------------------------------------------- |
| [`posthog`](https://readynative.app/docs/features/analytics/posthog) | PostHog (default) (most people pick this) | yes | product analytics + feature flags (analytics.isFeatureEnabled / useFeatureFlag) · Expo Go OK |
| [`amplitude`](https://readynative.app/docs/features/analytics/amplitude) | Amplitude | yes | product analytics · Expo Go OK (JS SDK, storage adapter) |
| [`none`](https://readynative.app/docs/features/analytics/none) | No analytics | yes | analytics.track/identify/screen are no-ops, feature flags read undefined |
* [`posthog`](https://readynative.app/docs/features/analytics/posthog): requires `storage` ∈ `kv-store` | `mmkv` | `async-storage`; needs `EXPO_PUBLIC_POSTHOG_KEY`
* [`amplitude`](https://readynative.app/docs/features/analytics/amplitude): requires `storage` ∈ `kv-store` | `mmkv` | `async-storage`; needs `EXPO_PUBLIC_AMPLITUDE_KEY`
## Crash reporting (`crash`) [#crash-reporting-crash]
| Option | Label | Expo Go | Hint |
| --------------------------------------- | ---------------------------------------- | ------- | --------------------------------------------------------- |
| [`sentry`](https://readynative.app/docs/features/crash/sentry) | Sentry (default) (most people pick this) | yes | JS + native crash reporting · Expo Go OK (JS errors only) |
| [`none`](https://readynative.app/docs/features/crash/none) | No crash reporting | yes | crash.capture is a no-op |
* [`sentry`](https://readynative.app/docs/features/crash/sentry): needs `EXPO_PUBLIC_SENTRY_DSN`
## Push notifications (`push`) [#push-notifications-push]
| Option | Label | Expo Go | Hint |
| -------------------------------------------------------------- | ---------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
| [`expo-notifications`](https://readynative.app/docs/features/push/expo-notifications) | Expo Notifications (default) (most people pick this) | no - dev build required | Expo push service + local notifications · dev build required for remote push (Expo Go: local only) |
| [`none`](https://readynative.app/docs/features/push/none) | No push notifications | yes | nothing registered |
* [`expo-notifications`](https://readynative.app/docs/features/push/expo-notifications): requires `storage` ∈ `kv-store` | `mmkv` | `async-storage`
## Backend (`backend`) [#backend-backend]
| Option | Label | Expo Go | Hint |
| ------------------------------------------------- | -------------------------------------------- | ------- | -------------------------------------------------------------------------- |
| [`api-routes`](https://readynative.app/docs/features/backend/api-routes) | API routes (default) (most people pick this) | yes | Expo Router +api.ts routes · src/server helpers · EAS Hosting · Expo Go OK |
| [`none`](https://readynative.app/docs/features/backend/none) | No API routes | yes | no src/app/api |
## Onboarding (`onboarding`) [#onboarding-onboarding]
| Option | Label | Expo Go | Hint |
| -------------------------------------- | ------------------------- | ------- | ------------------------------------------------------ |
| [`on`](https://readynative.app/docs/features/onboarding/on) | Onboarding flow (default) | yes | 3-slide intro at /onboarding, redirect until completed |
| [`off`](https://readynative.app/docs/features/onboarding/off) | No onboarding | yes | app opens straight into the tabs |
## Location (`location`) [#location-location]
| Option | Label | Expo Go | Hint |
| -------------------------------------------------------- | ----------------------------------------------- | ------- | ------------------------------------------------------------------------------- |
| [`expo-location`](https://readynative.app/docs/features/location/expo-location) | Expo Location (default) (most people pick this) | yes | foreground position + reverse geocoding, permission states handled · Expo Go OK |
| [`none`](https://readynative.app/docs/features/location/none) | No location | yes | no permission prompt, no expo-location |
## Home-screen widgets (`widgets`) [#home-screen-widgets-widgets]
| Option | Label | Expo Go | Hint |
| ----------------------------------------------------- | ---------------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| [`expo-widgets`](https://readynative.app/docs/features/widgets/expo-widgets) | Expo Widgets (default) (most people pick this) | no - dev build required | iOS home-screen widget + Live Activity (Lock Screen, Dynamic Island) in SwiftUI (@expo/ui), fed from the app · dev build required (no Expo Go) |
| [`none`](https://readynative.app/docs/features/widgets/none) | No widgets | yes | no app extension, widgets.update() is a no-op |
## Privacy consent (`consent`) [#privacy-consent-consent]
| Option | Label | Expo Go | Hint |
| ------------------------------------------- | ------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
| [`consent`](https://readynative.app/docs/features/consent/consent) | Consent sheet (GDPR / CCPA) (default) (most people pick this) | yes | asks before analytics + crash start (EU/EEA, UK, Switzerland, Canada, Brazil or everywhere); Settings toggles · Expo Go OK |
| [`none`](https://readynative.app/docs/features/consent/none) | No consent sheet | yes | consent.get() reads all-true; analytics and crash SDKs start as before |
## Testing (`testing`) [#testing-testing]
| Option | Label | Expo Go | Hint |
| ------------------------------------- | -------------------------------------------------------- | ------- | ------------------------------------------------- |
| [`jest`](https://readynative.app/docs/features/testing/jest) | Jest + Testing Library (default) (most people pick this) | yes | jest-expo preset, RNTL, tests for stores/theme/ui |
| [`none`](https://readynative.app/docs/features/testing/none) | No test runner | yes | no jest; module tests are removed |
# Config (/docs/config)
## `readynative.config.ts` [#readynativeconfigts]
The one file you edit. It must stay importable under plain Node (no React Native imports;
only *types* from `src/`), because `app.config.ts` reads it while Expo resolves the native
config. The shape is `ReadyNativeConfig` from `src/lib/readynative-config.ts`.
| Block | Field | Meaning |
| ---------- | ---------------------------------------- | -------------------------------------------------------------------------------------------- |
| `app` | `name` | Display name under the icon; variants append ` (Dev)` / ` (Preview)` |
| | `slug` | Expo project slug (EAS) |
| | `scheme` | Deep-link scheme, e.g. `readynative` → `readynative://` |
| | `owner` | Expo account / organisation slug owning the EAS project; empty → omitted |
| | `easProjectId` | EAS project id printed by `bunx eas-cli init`; feeds `extra.eas.projectId` + `updates.url` |
| | `ios.bundleId` | iOS bundle identifier; variants append `.dev` / `.preview` |
| | `android.package` | Android application id; same suffix rule |
| `brand` | `primary` | Hex colour; overrides the light `primary` token |
| | `accent` | Hex colour; exposed as the `brandAccent` token |
| | `radius` | Base corner radius in px (`radius.lg`); the other steps derive from it |
| | `font` | Font family name for the `fontFamily` token; you load the font file (see below) |
| `urls` | `website`, `support`, `privacy`, `terms` | Used by the settings screen; `support` may be a `mailto:` URL |
| `store` | `appStoreId` | Numeric App Store id for review prompts / store links |
| | `playPackage` | Play Store package (usually equals `app.android.package`) |
| `links` | `domain` | Bare host for iOS universal links / Android App Links (`app.example.com`). Empty → links off |
| | `appleTeamId` | 10-character Apple Team ID; prefixes the AASA app ids |
| | `androidCertFingerprints` | SHA-256 fingerprints of the Android signing certs (`eas credentials` prints them) |
| `features` | `reviewPrompt` | Ask for a store review (`expo-store-review`) |
| | `updatesBanner` | Show the "update available" banner backed by `expo-updates` |
Who reads it:
* `app.config.ts` - names, bundle ids, scheme, `associatedDomains` / `intentFilters`.
* `src/theme/tokens.ts` - `brand` → colour, radius and `fontFamily` tokens (then
`bun run gen:theme`).
* The settings screen - `urls`, `store`, `features`.
* `bun run doctor` - flags the `com.acme.app` placeholders, empty URLs / store ids, a
`links.domain` without `appleTeamId` / `androidCertFingerprints`, and an unlinked EAS project
(`app.easProjectId` empty and no `EAS_PROJECT_ID`) or a missing `updates.url`.
* `bun run gen:links` - `links` → `public/.well-known/apple-app-site-association` and
`assetlinks.json`.
Import it in app code through `@/lib/readynative` (ESLint blocks direct imports of
`readynative.config`).
### Brand font [#brand-font]
`brand.font` is only a name. Nothing in the tree loads a font file, so until you do, the name
points at a font the device doesn't have and text falls back to the system font. Put the file
under `assets/fonts/` and load it under exactly the name in `brand.font`, holding the splash
screen until it's ready:
```tsx title="src/app/_layout.tsx"
import { useFonts } from "expo-font";
export default function RootLayout() {
const [fontsLoaded] = useFonts({
Inter: require("@/assets/fonts/Inter-Regular.ttf"),
});
useEffect(() => {
if (!fontsLoaded) return;
hydrateThemeMode().finally(() => {
SplashScreen.hideAsync().catch((err: unknown) => {
console.warn("[splash] hideAsync failed", err);
});
});
}, [fontsLoaded]);
if (!fontsLoaded) return null;
// …the existing return
}
```
`expo-font` is already a dependency and `useFonts` works in Expo Go. The token reaches text
through the `stylesheet` and `unistyles` kits; with NativeWind set the family in your Tailwind
theme, with Tamagui in `tamagui.config.ts`. Each weight is its own file - load `Inter-Bold` under
its own name if you need real bold on Android.
### Universal links [#universal-links]
`links` is always wired but off until `domain` is set:
1. Set `domain`, `appleTeamId` and `androidCertFingerprints`.
2. `bun run gen:links` writes the two well-known files for the prod, preview and dev bundle
ids into `public/.well-known/`. Host them over https at that domain - `expo export -p web`
ships `public/` as-is, so the web build living there is enough.
3. Rebuild the native app: `associatedDomains` and `intentFilters` are native config that
`app.config.ts` emits only when `links.domain` is non-empty.
Test on a simulator with `npx uri-scheme open https:///x --ios` (or `--android`).
## `app.config.ts` and variants [#appconfigts-and-variants]
`app.config.ts` is hand-written and stays that way; modules never edit it. It derives three
variants from `APP_VARIANT` (default `dev`):
| `APP_VARIANT` | Name suffix | Bundle id / package suffix | EAS profile |
| ------------- | ------------ | -------------------------- | ------------- |
| `dev` | ` (Dev)` | `.dev` | `development` |
| `preview` | ` (Preview)` | `.preview` | `preview` |
| `prod` | *(none)* | *(none)* | `production` |
So `com.acme.app` becomes `com.acme.app.dev` for the dev build and all three can be installed
side by side. `eas.json` sets `APP_VARIANT` and `EXPO_PUBLIC_APP_VARIANT` per profile; locally
they come from `.env` (`.env.example` starts with `APP_VARIANT=dev`). The value is also exposed
as `extra.appVariant` and validated in `src/lib/env.ts`.
Other fixed choices in `app.config.ts`: `runtimeVersion: { policy: "appVersion" }`, typed
routes and the React Compiler on (`experiments`), and `web.output: "static"`.
`owner`, `extra.eas.projectId` and `updates.url` all come from `readynative.config.ts`:
`easProjectId` (or `EAS_PROJECT_ID`, which wins) becomes `extra.eas.projectId` and
`updates.url: https://u.expo.dev/`. While the id is empty the whole `updates` key is
omitted - an empty URL would break `expo-updates`. `owner` is omitted when empty. See
[Ship to TestFlight](https://readynative.app/docs/ship-to-testflight#2-link-the-eas-project).
### Module app patch [#module-app-patch]
Modules contribute native config through `module.json` → `app.expo` (config plugins, extra
keys). `setup` merges every selected module's patch into `.readynative.json` → `modules.app`, and
`app.config.ts` applies it at load time: `plugins` are appended unique by plugin id, other
keys are deep-merged. Example: `push/expo-notifications` adds the `expo-notifications`
plugin, `auth/supabase` adds `expo-apple-authentication`.
## `.readynative.json` [#readynativejson]
Written by `setup`, read by `app.config.ts` and `doctor`:
```json
{
"version": 1,
"preset": "default",
"selection": { "ui": "nativewind4", "data": "react-query", "...": "..." },
"applied": true,
"modules": {
"app": { "expo": { "plugins": ["expo-apple-authentication"] } },
"postSetup": ["Supabase: create a project, …"],
"doctor": [{ "type": "env", "keys": ["EXPO_PUBLIC_SUPABASE_URL"], "module": "supabase" }]
},
"keepModules": true
}
```
`preset` is `null` when the selection matches no preset. `keepModules: false` plus a different
selection on the next `setup` run is the error that tells you `modules/` is gone.
## Environment keys [#environment-keys]
`.env` (gitignored) holds every key; `.env.example` (generated) lists them per module with a
`# docs:` link. Rules:
* Public keys start with `EXPO_PUBLIC_` and are the only ones rendered into `src/lib/env.ts`,
each as `z.string().optional()` - a missing key degrades the module ("Configure X"), it never
crashes at import time.
* `server: true` keys (`STRIPE_SECRET_KEY`, `BETTER_AUTH_SECRET`, …) have no prefix, live under
the `# server (never EXPO_PUBLIC)` section, are read with `process.env.X` only in
`src/app/api/**` / `src/server/**`, and never reach the bundle.
* `bun run doctor` checks every `required: true` key plus explicit `doctor` entries.
# Deploy your API routes (/docs/deploy-api-routes)
Pro
Hey - by the end of this page your API routes answer at `https://your-app.expo.app`, with their
server keys set on EAS, and your builds call that URL instead of your laptop.
While you develop, `bun run start` serves `src/app/api/**` from the dev server. A release build
has no dev server, so anything that talks to your routes - Stripe Checkout, Better Auth, account
deletion - needs them deployed first.
## Before you start [#before-you-start]
* **Tier:** Pro. The `backend=api-routes` module isn't in Free or Starter.
* **Time:** about 30 minutes.
* **Runs in:** anything - the routes run on EAS Hosting, not on the device, so Expo Go works too.
* **Accounts:** an Expo account with a linked EAS project
([Ship to TestFlight](https://readynative.app/docs/ship-to-testflight#2-link-the-eas-project) has the commands). EAS
Hosting has a free tier.
* **Previous tutorial:** [Add analytics and crash reporting](https://readynative.app/docs/add-analytics-and-crash).
**Picked `backend=api-routes` 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 it 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 --backend api-routes --yes --keep-modules
It patches the app config with `web.output: "server"`, which is what makes API routes build (the
static web export is off while it's selected), and adds `src/app/api/health+api.ts`,
`src/app/api/echo+api.ts`, `src/app/api/privacy/delete+api.ts` and the helpers in
`src/server/`.
**You should see:** `src/app/api/` and `src/server/` in your tree.
## 2. Run the routes locally [#2-run-the-routes-locally]
bun run start
In another terminal:
```bash
curl -s http://localhost:8081/api/health
```
**You should see:** `{"ok":true,"version":"1.0.0","variant":"dev"}` - the version is the one in
`package.json`.
## 3. Put the server keys on EAS [#3-put-the-server-keys-on-eas]
Deployed routes read their keys from an EAS environment, never from your `.env`. Server keys
(no `EXPO_PUBLIC_` prefix) go in as `sensitive` - **not** `secret`, which EAS Hosting can't
deploy. `EXPO_PUBLIC_*` keys your routes read (the Supabase ones, for example) go in as
`plaintext`. [Environment variables → On EAS](https://readynative.app/docs/environment-variables#on-eas) explains the
three visibilities.
```bash
bunx eas-cli env:set --name WEBHOOK_SECRET --value your-random-string --environment production --visibility sensitive
```
The others depend on your stack:
| Your stack | Server keys |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `payments=stripe` | `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET` (live values) |
| `auth=better-auth` | `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` (the deployed origin), `DATABASE_URL` |
| `auth=clerk` | `CLERK_SECRET_KEY` (or `CLERK_JWT_KEY`), `CLERK_WEBHOOK_SIGNING_SECRET` |
| `auth=supabase` | none - `EXPO_PUBLIC_SUPABASE_URL` and `_ANON_KEY` as `plaintext` |
| Account deletion (optional) | `POSTHOG_PERSONAL_API_KEY` + `POSTHOG_PROJECT_ID`, `AMPLITUDE_API_KEY` + `AMPLITUDE_SECRET_KEY`, `REVENUECAT_SECRET_KEY` |
```bash
bunx eas-cli env:list --environment production
```
**You should see:** every key your routes read, in the `production` environment.
## 4. Deploy [#4-deploy]
A server deploy runs in this order: pull the environment so the export sees the same values,
export the web bundle (which carries the API routes), then deploy with the same environment:
```bash
bunx eas-cli env:pull --environment production
bunx expo export --platform web
bunx eas-cli deploy --environment production
```
`env:pull` writes `.env.local`, which also wins over `.env` in local development - delete it
once the deploy is done. The first `eas deploy` asks you to pick a preview subdomain, say
`trailmix`. Re-run the export before every deploy.
**You should see:** a preview URL such as `https://trailmix--abc123xyz.expo.app` and a link to the
deployment on expo.dev.
## 5. Smoke-test it [#5-smoke-test-it]
```bash
curl -s https://trailmix--abc123xyz.expo.app/api/health
```
**You should see:** `{"ok":true,...}`. The `variant` field reads `dev` on a deploy - it
reflects `EXPO_PUBLIC_APP_VARIANT`, which only builds set.
Happy with it? Promote it to the production URL:
```bash
bunx eas-cli deploy --prod --environment production
```
That deploys `dist/` again as the production deployment, at `https://trailmix.expo.app`.
## 6. Point the app at it [#6-point-the-app-at-it]
`EXPO_PUBLIC_API_URL` is compiled into each build and update, so set it per EAS environment: the
production URL for `production` and, if you want a staging backend, an alias for `preview`
(`bunx eas-cli deploy --alias staging` gives you `https://trailmix--staging.expo.app`):
```bash
bunx eas-cli env:set --name EXPO_PUBLIC_API_URL --value https://trailmix.expo.app --environment production --visibility plaintext
bunx eas-cli env:set --name EXPO_PUBLIC_API_URL --value https://trailmix--staging.expo.app --environment preview --visibility plaintext
```
Then ship it: a new build ([Ship to TestFlight](https://readynative.app/docs/ship-to-testflight)) or, since it's only a
JavaScript value, an update ([Your first update](https://readynative.app/docs/your-first-update)):
```bash
bunx eas-cli update --channel production --environment production --message "Use hosted API"
```
Keep `http://:8081` in your local `.env` for development.
**You should see:** the installed build reach the hosted routes with your laptop's dev server
stopped - for example, the Stripe paywall shows its price, or Better Auth signs you in.
## 7. Re-wire what depends on it [#7-re-wire-what-depends-on-it]
Anything registered against your dev URL needs the deployed one now:
* **Stripe** (`/api/stripe/checkout`, `/entitlements`, `/webhook`, `/return`): in live mode, add a
webhook endpoint at `https://trailmix.expo.app/api/stripe/webhook` and set its `whsec_…` as
`STRIPE_WEBHOOK_SECRET` - see [Add a paywall](https://readynative.app/docs/add-a-paywall).
* **Better Auth** (`/api/auth/*`): `BETTER_AUTH_URL` must be the deployed origin, and every OAuth
redirect URI (`https://trailmix.expo.app/api/auth/callback/google`, `…/apple`) re-registered
against it. Swap the memory database for Postgres before real users arrive.
* **Account deletion** (`/api/privacy/delete`): Settings → Delete account calls it before the
auth provider deletes the user, to erase them in PostHog, Amplitude and RevenueCat when those
server keys are set. It answers 503 until server auth is configured.
* **Clerk** (`/api/webhooks/clerk`): point the `user.deleted` webhook at the deployed URL.
After changing keys, run step 4 again - a deploy reads the environment at deploy time.
**You should see:** if you use Stripe, successful deliveries to the new endpoint on the Stripe
dashboard's webhook page.
## Check it [#check-it]
```bash
curl -s https://trailmix.expo.app/api/health
```
bun run doctor
The health route should answer `{"ok":true,...}`, and every backend row should be green.
## If it doesn't work [#if-it-doesnt-work]
* **404 on every `/api/*` URL** - the export ran without the module's `web.output: "server"`, or
you deployed an old `dist/`. Run `bunx expo export --platform web` again, then deploy.
* **500 from a route that works locally** - a server key is missing from the environment you
deployed with, or it's stored as `secret`. Fix it with `env:set --visibility sensitive` and
deploy again.
* **The app still calls your laptop** - the build or update was made before
`EXPO_PUBLIC_API_URL` was set on EAS, or a leftover `.env.local` overrode it locally. Publish
an update with `--environment production`.
* **iOS refuses the request** - the URL must be https; EAS Hosting always is, so check for a
leftover `http://` value.
* **Local dev suddenly uses production values** - delete the `.env.local` that `env:pull` wrote.
More in [Troubleshooting](https://readynative.app/docs/troubleshooting).
## Congrats 🎉 [#congrats-]
Your backend is live on EAS Hosting, with its keys on EAS and every build pointed at it - so
Checkout, sign-in and account deletion work without your laptop. To take the module out again,
see [API routes → Remove it](https://readynative.app/docs/features/backend/api-routes#remove-it). For when to outgrow
API routes, read [Do I need a backend?](https://readynative.app/docs/backend).
Next, get a build to testers: [Ship to TestFlight](https://readynative.app/docs/ship-to-testflight).
# Doctor (/docs/doctor)
bun run doctor
It prints one line per check and exits non-zero if any of them failed. `✓` is fine, `!` is
advisory, `✗` fails the run, and `-` means the check didn't apply to your selection.
Store and release identity - the bundle id, package, app name, `urls`, store ids, the EAS
project link, `eas whoami` and the placeholder app icon - is expected to be unset on day one, so
those rows are grouped at the end under **Before you ship** as `!`: a fresh `default` setup exits 0. `bun run doctor --store` turns every one of them into `✗`, because by then they block a
submission.
```
✓ .readynative.json present (preset saas)
✗ EXPO_PUBLIC_SUPABASE_URL (auth/supabase) (missing in .env · dashboard: https://supabase.com/dashboard · guide: https://readynative.app/docs/features/auth/supabase#setup)
✓ package manager (bun (bun.lock))
Before you ship:
! readynative.config.ts app.ios.bundleId set (still "com.acme.app" - set your own reverse-DNS id)
! app icon replaced (assets/images/icon.png is the ReadyNative placeholder - …)
```
## The rows [#the-rows]
| Row | Green when | If it's red |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `.readynative.json present` | `setup` has run and applied a selection. The detail shows the preset, or `custom` | Run `bun run setup` |
| One row per env key | The key is set in `.env` or in the process environment | Follow the two links in the detail (below) |
| `readynative.config.ts` placeholders | No `com.acme.app`, no default app name, no empty `urls` or `store` ids (before you ship), and a `links.domain` that has a team id and fingerprints | Fill them in - see [Config](https://readynative.app/docs/config) |
| `app icon replaced` | `assets/images/icon.png` and `assets/brand/icon.png` are not the shipped placeholder (before you ship) | Put your 1024px PNG at `assets/brand/icon.png` and run `bun run gen:assets` |
| `eas whoami` | You're logged in (before you ship). Skipped entirely when `eas-cli` is not installed | `bunx eas-cli login` - see [Ship to TestFlight](https://readynative.app/docs/ship-to-testflight) |
| Xcode / Android SDK | `xcode-select -p` resolves and `ANDROID_HOME` is set. Only checked when a selected module is `expoGo: false` | Install the toolchain, or build the dev build on EAS instead |
| `bun >= 1.2` / `node >= 22.18` | Both runtimes are on your PATH and recent enough (bun only fails for a bun tree) | Install Node 24 LTS: below 22.18 node cannot run `scripts/*.ts` |
| `package manager` | Always. It reports the answer rather than judging it: `pnpm (pnpm-lock.yaml)` | See [Package managers](https://readynative.app/docs/package-managers) if it isn't the one you expected |
## The two links on a missing key [#the-two-links-on-a-missing-key]
A missing env key prints where to get it and how to wire it up:
```
missing in .env · dashboard: · guide:
```
The **dashboard** link comes from the module's `env[].docsUrl` - the exact vendor page that
issues that key. The **guide** link is the `#setup` anchor of the module's Features page here,
which is the numbered walkthrough naming every dashboard screen in order. Either part is
dropped when a module doesn't carry it, so a keyless module still reads as a sentence.
`setup` prints the same guide link once per selected service when it finishes, so you can start
collecting keys before you ever run `doctor`.
## The docs link at the bottom [#the-docs-link-at-the-bottom]
After the checklist, `doctor` prints:
```
Docs for your stack: https://readynative.app/docs/?stack=ui:nativewind4,auth:supabase,…
```
That query string is your `.readynative.json` selection. Open it and this whole site switches to
your stack: the tutorials show your options' code, the Features tree hides what you didn't pick.
You can also set it by hand in the **Your stack** panel at the top of the sidebar, or load it
there with **Import `.readynative.json`**.
Point the links at a local docs server by setting `READYNATIVE_DOCS_URL`.
## Running it elsewhere [#running-it-elsewhere]
`--root ` runs the checks against another checkout:
bun run doctor --root ../my-other-app
# Environment variables (/docs/environment-variables)
Every key lives in `.env` at the repo root (gitignored). `bun run setup` generates
`.env.example` with exactly the keys your selection needs, each with a `# docs:` link to the
dashboard that issues it; copy it to `.env` and fill in what you use. `bun run doctor` flags
the required ones that are still empty.
There are two kinds, and the prefix decides which:
* **`EXPO_PUBLIC_*` keys are public.** Metro inlines them into the JavaScript bundle, so anyone
with the app can read them. Only publishable / anon / SDK keys belong here. They're typed in
`src/lib/env.ts` as optional: a missing key makes its module show a "Configure X" state
instead of crashing. Restart with `bun run start -- -c` after changing one.
* **Keys without the prefix are server-only.** They're read with `process.env.X` inside
`src/app/api/**` and `src/server/**` (API routes, Pro with `backend=api-routes`) and never
reach the bundle. Never rename
one to `EXPO_PUBLIC_`. Build-time keys (`APP_VARIANT`, `SENTRY_AUTH_TOKEN`, …) work the same
way: `app.config.ts` and config plugins read them while EAS builds, and the app never sees
them.
## Template [#template]
Present in every tree, whatever you picked. The Free tier has no `eas.json`, so the per-profile
values don't apply there.
| Variable | Kind | Default | What it does |
| ------------------------- | ---------- | ------- | ------------------------------------------------------------------------------------------------------- |
| `EXPO_PUBLIC_APP_VARIANT` | public | `dev` | `dev` / `preview` / `prod` at runtime; set per profile in `eas.json` |
| `EXPO_PUBLIC_API_URL` | public | - | Base URL for relative requests (data clients, API routes, Stripe, Better Auth). Use `https` on a device |
| `APP_VARIANT` | build time | `dev` | Picks the name and bundle-id suffix in `app.config.ts`; set per profile in `eas.json` |
| `EAS_PROJECT_ID` | build time | - | Overrides `app.easProjectId`; feeds `extra.eas.projectId` and `updates.url` |
`EXPO_PUBLIC_API_URL` on a physical iPhone must be `https://`, a LAN IP, `localhost` or
`*.local`: App Transport Security blocks plain `http://` to anything else, and `src/lib/env.ts`
warns in dev when it sees one.
## Modules [#modules]
Only the modules you selected add keys. Every row below is a Pro module (auth, payments,
analytics, crash, backend) except `data/apollo`, which Starter can pick too; the
`backend/api-routes` rows, `src/server/webhooks` included, exist only in a Pro tree set up
with `backend=api-routes`. **Required** means the module stays in its
"Configure" state (and `doctor` fails) until the key is set.
| Variable | Module | Kind | Required | What it is |
| ------------------------------------ | ------------------- | ---------- | -------------- | --------------------------------------------------------------------------------------- |
| `EXPO_PUBLIC_SUPABASE_URL` | auth/supabase | public | yes | Project URL (Supabase → Project settings → API) |
| `EXPO_PUBLIC_SUPABASE_ANON_KEY` | auth/supabase | public | yes | Anon / publishable key |
| `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` | auth/clerk | public | yes | Clerk publishable key |
| `CLERK_SECRET_KEY` | auth/clerk | server | for API routes | Clerk secret key (`sk_…`); lets API routes verify the caller's session token |
| `CLERK_JWT_KEY` | auth/clerk | server | no | JWT public key (PEM), verifies tokens networkless instead of `CLERK_SECRET_KEY` |
| `BETTER_AUTH_SECRET` | auth/better-auth | server | yes | Session signing secret, `openssl rand -base64 32` |
| `BETTER_AUTH_URL` | auth/better-auth | server | yes | Callback base URL, same as `EXPO_PUBLIC_API_URL` |
| `DATABASE_URL` | auth/better-auth | server | no | Postgres connection string for persistent users and sessions |
| `APPLE_CLIENT_ID` | auth/better-auth | server | no | Apple Services ID for Sign in with Apple |
| `APPLE_CLIENT_SECRET` | auth/better-auth | server | no | Apple client secret (JWT) |
| `GOOGLE_CLIENT_ID` | auth/better-auth | server | no | Google OAuth client id |
| `GOOGLE_CLIENT_SECRET` | auth/better-auth | server | no | Google OAuth client secret |
| `EXPO_PUBLIC_REVENUECAT_IOS_KEY` | payments/revenuecat | public | yes | RevenueCat public SDK key for iOS (`appl_…`) |
| `EXPO_PUBLIC_REVENUECAT_ANDROID_KEY` | payments/revenuecat | public | yes | RevenueCat public SDK key for Android (`goog_…`) |
| `EXPO_PUBLIC_ADAPTY_PUBLIC_KEY` | payments/adapty | public | yes | Adapty public SDK key |
| `EXPO_PUBLIC_ADAPTY_PLACEMENT_ID` | payments/adapty | public | no (`default`) | Placement whose paywall is shown |
| `EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY` | payments/stripe | public | yes | Stripe publishable key (`pk_…`) |
| `STRIPE_SECRET_KEY` | payments/stripe | server | yes | Stripe secret key (`sk_…`) |
| `STRIPE_WEBHOOK_SECRET` | payments/stripe | server | yes | Webhook signing secret (`whsec_…`) |
| `STRIPE_PRICE_ID` | payments/stripe | server | yes | Recurring price id (`price_…`) |
| `STRIPE_ENTITLEMENT` | payments/stripe | server | no (`pro`) | Entitlement an active or trialing subscription grants |
| `STRIPE_ALLOW_ANONYMOUS` | payments/stripe | server | no (`false`) | `true` lets checkout run with auth `none`, trusting client ids - demos only |
| `EXPO_PUBLIC_POSTHOG_KEY` | analytics/posthog | public | yes | PostHog project API key |
| `EXPO_PUBLIC_POSTHOG_HOST` | analytics/posthog | public | no (US cloud) | `https://eu.i.posthog.com` for the EU cloud |
| `EXPO_PUBLIC_AMPLITUDE_KEY` | analytics/amplitude | public | yes | Amplitude API key |
| `EXPO_PUBLIC_SENTRY_DSN` | crash/sentry | public | yes | Sentry DSN |
| `SENTRY_ORG` | crash/sentry | build time | no | Org slug, for source map uploads on native builds |
| `SENTRY_PROJECT` | crash/sentry | build time | no | Project slug, same purpose |
| `SENTRY_AUTH_TOKEN` | crash/sentry | build time | no | Auth token with `project:releases` + `org:read`; without it, stack traces stay minified |
| `EXPO_PUBLIC_GRAPHQL_URL` | data/apollo | public | no | GraphQL endpoint |
| `WEBHOOK_SECRET` | backend/api-routes | server | no | Shared secret for incoming webhooks under `src/server/webhooks` |
| `CLERK_WEBHOOK_SIGNING_SECRET` | backend/api-routes | server | no | Signing secret of the Clerk `user.deleted` webhook (`/api/webhooks/clerk`) |
| `POSTHOG_PERSONAL_API_KEY` | backend/api-routes | server | no | Lets account deletion erase the user in PostHog |
| `POSTHOG_PROJECT_ID` | backend/api-routes | server | no | PostHog project id, same purpose |
| `POSTHOG_API_HOST` | backend/api-routes | server | no | PostHog API host, same purpose |
| `AMPLITUDE_API_KEY` | backend/api-routes | server | no | Lets account deletion erase the user in Amplitude |
| `AMPLITUDE_SECRET_KEY` | backend/api-routes | server | no | Amplitude secret key, same purpose |
| `AMPLITUDE_REGION` | backend/api-routes | server | no | Amplitude region, same purpose |
| `REVENUECAT_SECRET_KEY` | backend/api-routes | server | no | Lets account deletion erase the customer in RevenueCat |
Each module page under [Features](https://readynative.app/docs/features) has the dashboard steps for its keys. The two
[example apps](https://readynative.app/docs/examples) carry their own `.env.example` with the subset they use.
## On EAS [#on-eas]
Your `.env` is gitignored, so EAS never uploads it: a cloud build, `eas update` and an EAS
Hosting deploy all run without it. A key that works locally but is missing in a TestFlight build
almost always was never set on EAS.
Set each key in the EAS environment it's used in (`development`, `preview`, `production`). A
build picks the environment from its profile: `build..environment` in `eas.json` if you
add one, otherwise `production` for store builds (the shipped `production` profile),
`development` for dev-client builds (`development`) and `preview` for the rest:
```bash
bunx eas-cli env:set --environment production --name EXPO_PUBLIC_SUPABASE_URL --value https://xyz.supabase.co --visibility plaintext
bunx eas-cli env:set --environment production --name STRIPE_SECRET_KEY --value sk_live_xxx --visibility sensitive
bunx eas-cli env:list --environment production
```
`env:set` creates or updates a variable (the older `env:create` still works but is deprecated).
`--visibility` takes three values:
* **`plaintext`** - visible on expo.dev, in EAS CLI and in logs. Fine for `EXPO_PUBLIC_*` keys:
they end up in the bundle anyway.
* **`sensitive`** - masked in build and workflow logs, still readable in EAS CLI and on the
dashboard behind a toggle. Use it for server keys your API routes read (`STRIPE_*`,
`BETTER_AUTH_*`, `CLERK_SECRET_KEY`, `WEBHOOK_SECRET`): **EAS Hosting can't deploy `secret`
variables**, only `plaintext` and `sensitive` ones. Also use it for `SENTRY_AUTH_TOKEN` if you
upload source maps after `eas update` from your machine.
* **`secret`** - never readable outside EAS servers, not even by EAS CLI. Right for keys only an
EAS build job needs (`SENTRY_AUTH_TOKEN` for native builds, an `NPM_TOKEN`). A secret isn't
available while EAS CLI resolves `app.config.ts` on your machine, can't be pulled, and isn't
used by `eas update`.
`bunx eas-cli env:pull --environment development` writes that environment's variables to
`.env.local` (pass `--path` for another file); `secret` ones appear only as a commented-out
`*****` line. Expo loads `.env.local` on top of `.env`, so a pulled value wins over the one in
`.env`. `bun run doctor` reads only `.env`, though, so it still lists pulled keys as missing;
pull with `--path .env` (it asks before overwriting) if you want doctor to see them.
`eas update` needs `--environment` on SDK 55 and later - see
[Your first update](https://readynative.app/docs/your-first-update#3-publish-it). `APP_VARIANT` and
`EXPO_PUBLIC_APP_VARIANT` are already set per profile in `eas.json`, so don't create them.
Your tree ships no CI workflows. If you add your own (see
[Expo's GitHub Actions guide](https://docs.expo.dev/eas-update/github-actions/)), EAS needs an
`EXPO_TOKEN` secret (expo.dev → Account settings → Access tokens); set `EAS_PROJECT_ID` there
too if you'd rather not commit the id.
# Example apps (/docs/examples)
## The weather demo [#the-weather-demo]
Starter and Pro open on a small weather app, so the first `bun ios` shows what the modules do
together instead of an empty screen. It is demo content: `bun run setup` removes it and leaves
a plain welcome screen, unless you pass `--with-examples`.
```bash
bun install
bun ios # or: bun run start, for Expo Go
```
| What you see | What it is built on |
| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The forecast where you are, with a "Location is off" fallback | `@/lib/location` ([location](https://readynative.app/docs/features/location)) - `null`, never an exception, when permission is denied |
| Hourly strip, 10-day list with range bars, detail tiles, pull to refresh | `@/ui` primitives only, so it renders the same on every UI stack |
| A native tab bar (Liquid Glass on iOS 26), large-title Cities stack, header search, context menus on each city | `NativeTabs`, native stack headers, `Stack.SearchBar`, `Link.Menu` from Expo Router |
| City search in the app language, °C / °F | Open-Meteo (free, no key), the persisted store on `createPersistedStore` |
| The last forecast on the first frame, and offline | `@/lib/cache` feeding `useForecast()`, which each [data module](https://readynative.app/docs/features/data) ships in its own idiom |
| English, Spanish, Russian, Chinese, Portuguese and Arabic - Arabic mirrors the layout | the [i18n](https://readynative.app/docs/features/i18n) module and `Intl` for dates, hours and digits |
| The temperature on your Home Screen, and live on the Lock Screen / Dynamic Island | `@/lib/widgets` ([widgets](https://readynative.app/docs/features/widgets)): `widgets.update()` and `widgets.activity.start()` - no-ops until you pick `--widgets expo-widgets` |
| Tap a day for its details in a native sheet | `Sheet` from `@/ui` (`@expo/ui`'s SwiftUI / Material 3 sheet) |
| System toasts, presses that track your finger at 120 Hz | `toast.show()` (Burnt on iOS builds) and `Pressable` (Pressto) from `@/ui` |
| "See all 10 days with Pro", more than 3 saved cities | `@/lib/payments`; with `--payments none` nothing is locked |
| Share, "Notify me" and analytics events | `@/lib/share`, `@/lib/push`, `@/lib/analytics` - each a no-op when its module is `none` |
Where it lives, if you want to copy a pattern before setup deletes it:
* Screens - `src/screens/home/home-screen.tsx`, `src/screens/weather/*`
* Routes - `src/app/(tabs)/index.tsx`, `src/app/(tabs)/cities/{_layout,index,[id]}.tsx`
* Open-Meteo client, WMO codes, `Intl` formatting - `src/lib/weather/*`
* Saved cities and units - `src/stores/weather.ts`
* The forecast hook per data module - `src/hooks/use-forecast.ts`
Right-to-left needs a dev build: Expo Go resets the layout direction when it opens a project,
so Arabic text shows but the layout stays left-to-right there. The widget needs a dev build too.
The weather data comes from [Open-Meteo](https://open-meteo.com) under CC BY 4.0; the demo
credits it under the forecast. Keep that line if you keep the API.
## Pro example apps [#pro-example-apps]
Pro
These are the apps I use to prove the modules work together. Each one is a **standalone app**:
`examples//` has its own `package.json`, lockfile and `.env.example`, and it boots with no
`.env` at all. Every screen is usable, and anything that needs a key you haven't set says so
*on that screen*, before you act, naming the exact variable. Both use the native tab bar
(`NativeTabs`), native stack headers with large titles and inset-grouped settings. Nothing in
your app imports `examples/`, so delete it whenever you like.
| Example | What it shows | Platforms | Tier |
| --------------------------- | ------------------------------------------------------------------------------- | -------------------------------------- | ---- |
| [Field Notes](#field-notes) | Local-first journal with Supabase sync, reminders, place + weather, a paid tier | iOS, Android | Pro |
| [Snap Recipe](#snap-recipe) | On-device food recognition, recipe matching, two payment rails | iOS; Android without photo recognition | Pro |
Both apps sell their own paid tier (a RevenueCat entitlement called `pro`). That's the
example's paid tier, a feature of the demo app - not the ReadyNative Pro tier you bought.
## Field Notes [#field-notes]
A private journal in `examples/notes`: one note per moment - a title, a few lines, optionally a
photo and where you were. "Where I am" stamps the note with a place name (`expo-location`
reverse geocoding) and the weather right now (Open-Meteo), both keyless. Notes are written to
the device first and sync to Supabase once you sign in; a daily local notification nudges you
to write; the example's paid tier unlocks unlimited notes and export. It's the `saas` preset
plus `expo-image-picker`, `expo-location` and `@react-native-community/datetimepicker`.
```bash
cd examples/notes
bun install
bun ios
bun run test
```
`bun ios` (or `bun android`) compiles a dev build with `expo run:*`, which RevenueCat needs;
afterwards `bun run start:dev` serves it. `bun start` is `expo start --go` and runs everything
else in Expo Go. The camera only appears on a physical device.
### Where each pattern lives [#where-each-pattern-lives]
* Local-first store (zustand `persist` on `zustandStorage`) - `src/stores/entries.ts`
* Sync algebra: last-writer-wins, tombstones, cursor, with tests - `src/features/entries/merge.ts`
* Supabase push/pull on sign-in, foreground and after edits - `src/lib/sync.ts`,
`supabase/entries.sql`
* Paid-tier gate that switches itself off while RevenueCat is unconfigured - `src/lib/pro.ts`
(`isProGateActive`)
* Keyless place + weather stamp - `src/lib/weather.ts`, `src/features/entries/weather.ts`
* Daily local reminder that deep-links to `/entry/new` - `src/lib/reminders.ts`
* Large-title list with header search and swipe-to-delete -
`src/screens/entries/entries-screen.tsx`, `entry-row.tsx`
* Modal editor with Cancel/Add in the navigation bar -
`src/screens/entries/entry-editor-screen.tsx`
* Stacks nested in native tabs - `src/app/(tabs)/**`
* Inset-grouped settings with a native switch and time picker -
`src/screens/settings/settings-screen.tsx`
* One shared "needs keys" state naming the env vars - `src/components/not-configured.tsx`
* Auth screens and a paywall that explain a missing key - `src/screens/auth/*`,
`src/screens/paywall/paywall-screen.tsx`
* Deep link `fieldnotes://entry/` - `src/app/(tabs)/(notes)/entry/[id].tsx`
* Empty-env boot test, keyless screen tests (list, settings, the "needs keys" state), Maestro
flows - `src/__tests__/boot.test.tsx`, `src/screens/__tests__/*`, `.maestro/flows/*.yaml`
### Keys [#keys]
`examples/notes/README.md` has the full table. The short version: the Supabase URL and anon
key unlock accounts and cloud sync (run `supabase/entries.sql` first for the table and RLS);
the two RevenueCat keys turn the paid-tier gate *on* (without them nothing is capped, and
Settings and the paywall say so); `EXPO_PUBLIC_POSTHOG_KEY`, `EXPO_PUBLIC_SENTRY_DSN` and
`EXPO_PUBLIC_API_URL` are optional no-ops when empty. The daily reminder is a *local*
notification, so it works in Expo Go with no EAS project; remote push needs a linked EAS
project and a physical device.
Not included: photos stay on the device (no Supabase Storage upload), there is no "choose a new
password" screen, conflicts resolve silently in favour of the newer edit, and the weather stamp
is sampled when you tap "Where I am" - editing an old note re-stamps it with the weather now.
## Snap Recipe [#snap-recipe]
Photograph your fridge in `examples/snap-recipe`. MobileCLIP recognises the food **on the
phone** - no server, no key, the photo never leaves the device - and a bundled recipe book
suggests what to cook, marking which ingredients you already have. Save favourites, share a
recipe as text. Three scans are free; the example's paid tier makes them unlimited and sells
through **two rails**: native in-app purchase (App Store / Google Play via RevenueCat) and
Stripe Checkout. It's the `default` preset plus Clerk, API routes, Stripe, RevenueCat, Sentry
and PostHog.
```bash
cd examples/snap-recipe
bun install
bun run gen:clip
bun ios
bun run test
```
`bun start` is `expo start --go`: everything but photo recognition and RevenueCat purchases
runs in Expo Go. `bun ios` builds the dev build those two need; `bun run start:dev` serves it.
`bun run gen:clip` downloads MobileCLIP-S0 (about 110 MB, cached) and writes the class
embeddings for the recipe catalog; the weights aren't in git. It needs macOS (Core ML runs the
text encoder) and `python3`. Run it once per machine, and again after changing the ingredient
list in `src/features/recipes/catalog.ts`.
### Android and other platforms [#android-and-other-platforms]
Photo recognition is an iOS-only local module, so it runs on an iPhone or the iOS Simulator
from a dev build. On Android, the web and in Expo Go the scan screen says "Photo recognition
runs on iPhone" in place. The rest works there: **Try a sample fridge** opens a bundled photo
with three pre-baked recipes (`src/features/recipes/sample.ts`), so results, detail,
favourites and share run anywhere with zero keys. Sample scans are marked "Sample" and never
count against the free quota. `bun android` compiles the Android dev build, which the Google
Play rail needs.
Without the generated model, an iOS build falls back to Apple's built-in Vision classifier
(device only, a fixed vocabulary). For EAS builds, run `bun run gen:clip` in an
`eas-build-post-install` script, or the cloud build ships the fallback.
### Where each pattern lives [#where-each-pattern-lives-1]
* Local Expo module in Swift running MobileCLIP zero-shot over the photo and a grid of crops -
`modules/food-vision`
* Label → ingredient → recipe matching, with tests - `src/features/recipes/catalog.ts`
* On-device downscale with `expo-image-manipulator` behind a react-query mutation -
`src/lib/scan.ts`
* Two payment rails behind one payments contract, entitlements are the union -
`src/lib/payments.ts`, `src/lib/store-purchases.ts`
* Stripe Checkout routes (checkout, entitlements, return, webhook) - `src/app/api/stripe/*`,
`src/server/stripe.ts`
* Paid-tier gating on `payments.useEntitlements()` - `src/lib/quota.ts`
* One native stack per tab with a shared `recipe/[id]` route - `src/app/(tabs)/**`,
`src/components/app-stack.tsx`
* Scans, favourites and the free counter persisted with zustand - `src/stores/scans.ts`
* Empty-env boot test, RevenueCat and Stripe tests with the SDKs mocked -
`src/__tests__/boot.test.tsx`, `src/lib/__tests__/*`,
`src/server/__tests__/stripe-routes.test.ts`
* Maestro: fresh install, no keys → sample fridge → results - `.maestro/flows/scan-empty.yaml`
### Paywall and keys [#paywall-and-keys]
Hitting the scan limit opens RevenueCat's **native paywall** as a modal (Apple's purchase sheet
on buy); `/paywall` lists the store plans with "Compare all plans", and Stripe as "Or pay by
card" only where App Review allows it (US storefront on iOS). Settings has "Manage" on the
paid-tier row and, in dev builds, Developer → **Preview paywall**.
[Add web checkout next to the App Store](https://readynative.app/docs/add-web-checkout) walks through the same setup
for your own app.
`examples/snap-recipe/README.md` lists every key and what happens without it. Server-only keys
(`STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`) have no `EXPO_PUBLIC_` prefix and must never gain one;
on EAS Hosting set them with `--visibility sensitive` - `secret` variables can't be deployed to
EAS Hosting (see [Environment variables](https://readynative.app/docs/environment-variables)).
The RevenueCat keys are public SDK keys. `examples/snap-recipe/docs/RECOGNITION-MODELS.md`
compares the models you can swap in (MobileCLIP, YOLO26, RF-DETR, Create ML, barcode and text
reading, cloud vision) with their licences.
* **MobileCLIP licence:** the Hugging Face weights point at a permissive Apple licence, but
Apple's repo moved MobileCLIP to a **research-only** licence in August 2025. Treat the model
as research-only until you've settled it: read `modules/food-vision/MODEL-LICENSE.md`, and
swap in a permissively licensed model for a commercial release.
* **The store rail is in-app purchase, not Apple Pay.** Apple's sheet charges the Apple ID's
payment method. Apple Pay proper is only for physical goods and services.
* **App Review 3.1.1:** digital subscriptions in an iOS app must be offered through in-app
purchase. A Stripe link-out is allowed on the US storefront; elsewhere it can get the build
rejected.
* **Disclosure:** show price, period, auto-renewal and how to cancel next to the buy button,
and link Terms and Privacy (`urls` in `readynative.config.ts`).
* **Plain-http API on a device:** iOS only allows `http://` to LAN IPs, `localhost`, `*.local`
and dot-less hosts. A tunnel or deployed API must be `https://`.
* **The free-scan counter lives on the device.** Count scans per user server-side if the free
tier must hold.
# Expo Go or a dev build (/docs/expo-go-vs-dev-build)
Hey - by the end of this page you'll know whether your stack runs in Expo Go, and if it doesn't,
you'll have a dev build on your simulator or phone that `bun run start:dev` connects to.
**Expo Go** is the app from the App Store and Play Store. It carries a fixed set of native
modules, so it opens your project in seconds with no build - as long as your stack needs nothing
more. A **dev build** is your own app with Expo's developer tools inside: every native module you
picked, your icon and bundle id, and the same fast refresh from your dev server. You build it
once and rebuild only when native code changes.
## Before you start [#before-you-start]
* **Tier:** any. On the Free tier every module runs in Expo Go.
* **Time:** none if you stay in Expo Go. A local dev build takes 10-20 minutes the first time; an
EAS one about 20-40 minutes in the queue.
* **Accounts:** none locally. For EAS, a free [Expo account](https://expo.dev/signup), plus the
paid Apple Developer Program to install an iOS build on a real iPhone.
* **Previous tutorial:** [Quickstart](https://readynative.app/docs/ship-in-5-minutes).
## 1. Check what your stack needs [#1-check-what-your-stack-needs]
Six options have native code Expo Go doesn't carry:
| Option | Why it needs a dev build |
| ------------------------- | --------------------------------------------------- |
| `storage=mmkv` | MMKV is a native key-value store |
| `ui=unistyles` | Unistyles is a C++ style engine on Nitro Modules |
| `payments=revenuecat` | StoreKit and Play Billing |
| `payments=adapty` | StoreKit and Play Billing |
| `push=expo-notifications` | Remote push - Expo Go can't receive it since SDK 53 |
| `widgets=expo-widgets` | The widget is an iOS app extension |
Two more run in Expo Go with less: `crash=sentry` reports JS errors there but needs a dev build
for native crashes, and `analytics=amplitude` fills in the full device context only in a dev
build. Local notifications work in Expo Go. Right-to-left layout (Arabic) also needs a dev build:
Expo Go resets the layout direction whenever it opens a project.
`setup` already made the call - look at the `start` script in `package.json`:
* `expo start --go` - every module you picked runs in Expo Go. `bun run start` opens it there.
* `expo start --dev-client` - at least one needs a dev build. `bun run start` targets your dev
build, and Expo Go won't load the project.
On Starter and Pro, `bun run doctor` checks the Xcode and Android SDK toolchains only when one of
your modules needs them. The Free tier has no `doctor`; before a local build, check by hand that
`xcode-select -p` prints a path inside `Xcode.app` (iOS) and that `ANDROID_HOME` points at your
Android SDK (Android).
**You should see:** `--go` or `--dev-client` in the `start` script. On `--go`, you're done -
come back when you add a native module.
## 2. Build it locally [#2-build-it-locally]
With Xcode (iOS) or Android Studio (Android) installed, one command generates the native
project, compiles it and installs it on the simulator or emulator:
bun run ios
bun run android
These are `expo run:ios` and `expo run:android`. The first run compiles every native module
from source, so it takes a while; later runs reuse the build cache. For a plugged-in phone, add
`--device`:
bunx expo run:ios --device
The `ios/` and `android/` folders this creates are generated - never edit them. Native
settings live in `app.config.ts`, `readynative.config.ts` and config plugins, and a rebuild
regenerates the folders.
**You should see:** "YourApp (Dev)" installed next to any other variant, opening the Expo
development launcher and then your app.
## 3. Or build it on EAS [#3-or-build-it-on-eas]
No Mac, or you'd rather not install Xcode? EAS builds it in the cloud with the `development`
profile from `eas.json` (`developmentClient: true`, internal distribution). Link the project
first - [Ship to TestFlight](https://readynative.app/docs/ship-to-testflight#2-link-the-eas-project) has the commands -
then:
```bash
bunx eas-cli build --profile development --platform android
```
The Android build is an `.apk` that installs on any phone or emulator from the link EAS prints.
iOS needs one more step. Internal builds install only on registered devices, so register your
iPhone first - the command shows a QR code to open on the phone, which installs a profile that
tells EAS the device's id:
```bash
bunx eas-cli device:create
bunx eas-cli build --profile development --platform ios
```
A device registered after a build needs a new build. To build for the iOS Simulator instead,
add a profile that extends `development`:
```json title="eas.json"
"development-simulator": {
"extends": "development",
"ios": { "simulator": true }
}
```
and build it with `--profile development-simulator`.
**You should see:** a QR code and install link when the build finishes. On iOS, enable
**Developer Mode** (Settings → Privacy & Security) the first time you open it.
## 4. Start the dev server for it [#4-start-the-dev-server-for-it]
bun run start:dev
That's `expo start --dev-client`: the QR code opens your dev build, not Expo Go. On a
`--dev-client` stack, `bun run start` does the same.
**You should see:** your dev build connect and load the app; saving a file refreshes it as in
Expo Go.
## When to rebuild [#when-to-rebuild]
Rebuild the dev build when something native changes: a module or library with native code, a
config plugin, the name, bundle id, scheme, icon or splash. JavaScript changes never need a
rebuild - they come from the dev server. When the app on the device and your tree disagree, the
dev build usually tells you with a "native module not found" error.
## If it doesn't work [#if-it-doesnt-work]
* **"Project is incompatible with this version of Expo Go" or a missing native module** - your
stack needs a dev build. See
[A native module is missing in Expo Go](https://readynative.app/docs/troubleshooting#a-native-module-is-missing-in-expo-go).
* **`bun run ios` fails before compiling** - `xcode-select -p` must point at Xcode (Starter and
Pro: the "Xcode (dev build)" row of `bun run doctor` checks it), or `ANDROID_HOME` at your
Android SDK for Android.
* **The iOS EAS build won't install** - the device wasn't registered when the build ran.
`bunx eas-cli device:create`, then build again.
* **The dev build opens but can't reach the dev server** - phone and computer must be on the same
network, or start with `bunx expo start --dev-client --tunnel`.
* **The build fails on credentials** - see
[EAS build or credentials fail](https://readynative.app/docs/troubleshooting#eas-build-or-credentials-fail).
More in [Troubleshooting](https://readynative.app/docs/troubleshooting).
## Next [#next]
You can run every module you picked. Next, make the app yours:
[Make it yours](https://readynative.app/docs/make-it-yours).
# FAQ (/docs/faq)
## Expo Go or a dev build? [#expo-go-or-a-dev-build]
Every module runs in [Expo Go](https://expo.dev/go) except the five that ship native code:
`storage=mmkv`, `ui=unistyles`, `payments=revenuecat`, `payments=adapty` and remote push with
`push=expo-notifications`. `bun run setup` prints the verdict for your selection
(`Expo Go: yes` / `NO - a dev build is required`), and each module page shows an Expo Go badge.
[Expo Go or a dev build](https://readynative.app/docs/expo-go-vs-dev-build) explains why and walks you through making
one.
## Can I change a module after `setup`? [#can-i-change-a-module-after-setup]
Only if `modules/` still exists. `setup` deletes it unless you passed `--keep-modules`; with the
folder present, this re-resolves the plan, copies the new module, removes the old one's files and
regenerates providers and env:
bun run setup --payments stripe --backend api-routes --yes --keep-modules
A finalized tree can't switch: `setup` is a stub once `modules/` is gone, and copying the folder
back from upstream doesn't restore the module system. Clone a fresh copy of your tier repo, run
`setup` there with the new selection, and port your own code (screens, stores,
`readynative.config.ts`) across. Removing a module by hand is possible - every module page has
a **Remove it** section listing its files, dependencies and env keys. Pulling fixes into a
finalized tree is covered in [Update from upstream](https://readynative.app/docs/update-from-upstream#finalized-tree).
## How do I add my own module? [#how-do-i-add-my-own-module]
This needs `modules/`, so it works in a tree set up with `--keep-modules`:
```bash
bun run gen module analytics mixpanel
```
creates `modules/analytics/mixpanel/{module.json,files/,docs.md}`. Then:
1. Fill `module.json`: `label`, `hint`, `expoGo`, `dependencies`, `files[]` (root-relative,
mirrored under `files/`), `providers`, `env` (every key with a `docsUrl`, secrets as
`server: true`), `requires` / `conflicts`, `postSetup`. The schema is zod in
`scripts/lib/schema.ts`; every field except `id` / `category` / `label` has a default.
2. For a service category, implement the shim contract from `src/lib/shims/.ts`
under `files/src/lib/.ts` with the same export names, so core code keeps
importing `@/lib/`.
3. Add the option to `CATALOG` in `scripts/lib/catalog.ts` (first entry = default).
4. Write `docs.md` (what it adds, keys, dashboards, a "Manual E2E checklist").
5. Apply it and check it: `bun run setup --analytics mixpanel --yes --keep-modules`, then
`bun run typecheck`, `bun run lint` and `bunx expo export -p ios -p android`.
The full contract is in the [module spec](https://readynative.app/docs/module-spec).
## Why is there no `className` on the kit's primitives? [#why-is-there-no-classname-on-the-kits-primitives]
Because three of the five UI stacks have no Tailwind. The primitives' props are token-typed
(`p={4}`, `bg="card"`, `radius="lg"`), and `style` is the single escape hatch, so the same
screens compile on `nativewind4`, `nativewind5`, `tamagui`, `unistyles` or `stylesheet`. Inside
a NativeWind kit you can use classes freely, and once `setup` has finalized your tree the kit in
`src/components/ui` is plain code you own - add `className` if you want it. See
[Styling](https://readynative.app/docs/styling).
## Does it run on the web? [#does-it-run-on-the-web]
`app.config.ts` sets `web.output: "static"` (`"server"` with `backend=api-routes`), and
`bun run web` starts `expo start --web`. Web is best-effort: the checks I run before each release
export iOS and Android only. NativeWind and Tamagui render fine on web; `unistyles` is
native-first (its web shell in `src/app/+html.tsx` is not a gate); RevenueCat and native push
have no web path. `expo export -p web` ships the `public/` folder (including `.well-known/` from
`gen:links`) as-is, which is enough to host universal-link files.
The Free tier ships without a `web` script and without `react-dom` and `react-native-web`, so it
doesn't run on the web out of the box. The `web` block in `app.config.ts` is still there; add
the two packages, then start the web target:
```bash
bunx expo install react-dom react-native-web
bunx expo start --web
```
## Why NativeWind 4 and not NativeWind 5 as the default? [#why-nativewind-4-and-not-nativewind-5-as-the-default]
NativeWind 5 is still a release candidate (`nativewind@5.0.0-rc.0` with
`react-native-css@3.1.0-rc.0`), so the default is the stable release: NativeWind 4.2 on
Tailwind 3 (`nativewind@4.2.7`), which the committed tree and every preset use and which gets
the most testing. The theme is generated as HSL triples into `tailwind.config.js`. Want
Tailwind 4 now? `bun run setup --ui nativewind5` - labelled "RC · Tailwind 4" in the picker -
gives you the same primitives on NativeWind 5, and adds the `lightningcss` override to
`package.json` that NativeWind 5 needs. When NativeWind 5 ships a stable release, it becomes
the default in an update.
## Tamagui and the React Compiler [#tamagui-and-the-react-compiler]
`experiments.reactCompiler` is on for every stack, including Tamagui 2 with the optimising
compiler: `tsc`, jest and `expo export` pass with both enabled. Only device runs can prove the
Reanimated press animations; if they misbehave, set
`"app": { "expo": { "experiments": { "reactCompiler": false } } }` in
`modules/ui/tamagui/module.json` (or your `.readynative.json` → `modules.app`) and rebuild. The
first Tamagui build prints a few benign `[tamagui] skipped loading … warning-001` lines and
writes a `.tamagui/` cache folder (gitignored).
## What does `bun run doctor` check? [#what-does-bun-run-doctor-check]
Your selection, the env keys your modules need, the placeholders in `readynative.config.ts`
(and that it loads under Node), your EAS login, the app icon, the native toolchains when a module
needs them, and the bun and Node versions. Store and release placeholders are listed under
"Before you ship" as warnings, so a fresh setup exits 0; `bun run doctor --store` turns them
into failures. Every row is explained in [Doctor](https://readynative.app/docs/doctor).
## Where do API keys go? [#where-do-api-keys-go]
In `.env` at the repo root (gitignored); `.env.example` is generated with every key your
selection needs and a `# docs:` link per key. `EXPO_PUBLIC_*` keys are bundled into the app, and
a missing one degrades its module ("Configure Supabase") instead of crashing. Keys without the
prefix (`STRIPE_SECRET_KEY`, `BETTER_AUTH_SECRET`, …) are server-only: they're never written to
`src/lib/env.ts`, so they can't leak into the bundle. For EAS builds, the same keys go in EAS
environment variables. [Environment variables](https://readynative.app/docs/environment-variables) has every key and
the visibility each kind needs on EAS.
## How long do updates last? [#how-long-do-updates-last]
For as long as ReadyNative is maintained - every paid license includes lifetime updates, and
your GitHub access stays. [License → Lifetime updates](https://readynative.app/docs/license#lifetime-updates) says what
"lifetime" means, and [Update from upstream](https://readynative.app/docs/update-from-upstream) shows how to pull a
release in.
## Can I get a refund? [#can-i-get-a-refund]
No refunds once you have access to the repo, except where the law requires otherwise; Polar,
the merchant of record, handles those. See [License → Refunds](https://readynative.app/docs/license#refunds).
## Why is `setup` one-way? [#why-is-setup-one-way]
So the shipped app is a plain Expo project: no runtime module registry, no unused adapters,
no dependencies for stacks you didn't pick. Keeping `modules/` around (`--keep-modules`) costs
nothing but disk space and keeps the door open for changes and upstream merges. [How setup
works](https://readynative.app/docs/how-setup-works) has what finalizing changes.
## How do I get help? [#how-do-i-get-help]
Through [the support form](https://readynative.app/support/) on the landing site - questions
before you buy, bugs, invoices, extra seats. That's the only channel: there's no support email,
so nothing gets lost in a mailbox. Paid but no repo? Open the customer-portal link in Polar's
receipt email and connect your GitHub account
([Get access](https://readynative.app/docs/after-you-buy#2-connect-github-in-the-polar-portal) has the steps). Still
stuck? Pick "Paid, no repo access" on
[the support form](https://readynative.app/support/?topic=access) and leave the email you paid
with.
# Amplitude (/docs/features/analytics/amplitude)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/analytics/amplitude - do not edit. */}
Pro
Runs in Expo Go; no dev build needed for this module.
This sends product analytics to Amplitude: `src/lib/analytics.ts` initialises `@amplitude/analytics-react-native` 1.x on the first analytics consent (only when the key is set) and implements `analytics.track/identify/screen`, with a pass-through `AnalyticsProvider` (order 40, since the SDK has no React provider). `screen` maps to `trackScreenView` (`[Amplitude] Screen Viewed`) and `identify` maps to `setUserId` plus an `Identify` call for user properties, where `null` unsets a property. If you set up with `--with-examples` there's an example at `/examples/analytics` with a list of the last events sent. `@/lib/analytics` keeps the same API whichever option you pick - pick Amplitude when your team already lives in its funnels and cohorts.
## Setup [#setup]
```bash
bun run setup --analytics amplitude
```
1. Open [app.amplitude.com](https://app.amplitude.com) → \[Settings] → \[Projects] → your project and copy the API key into `.env` as `EXPO_PUBLIC_AMPLITUDE_KEY`. It's publishable and safe in the client. Without it every call is a no-op and the example shows "Configure EXPO\_PUBLIC\_AMPLITUDE\_KEY".
2. If your data has to stay in the EU, add `serverZone: "EU"` to the `init` options in `src/lib/analytics.ts`.
3. Restart the dev server with `bun run start -- -c` so the key is picked up, then open \[Analytics] → \[User Look-Up] in Amplitude and watch for `[Amplitude] Session Start`.
4. Persistence - session and the event queue - goes through the storage adapter (`createAdapterStorage()` on `@/lib/storage`), so the SDK's default async-storage is never loaded; `disableCookies` is on and `migrateLegacyData` is off.
5. For the full device context (`Application Installed/Updated` events, `idfv`, app-set-id), make a dev build with `npx expo run:ios` or `npx expo run:android`. Expo Go still works - the SDK's native module is optional, and device and OS fields then come from the JS user agent.
Use a separate Amplitude project for production so your own test events don't skew the charts, and swap the key per build profile (`eas.json` `env`). The key is public, so there's nothing to keep secret - don't point release builds at your dev project.
6. Run `bun run doctor` - every row for this module should be green.
The keys, in one place:
| Key | Where to get it |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `EXPO_PUBLIC_AMPLITUDE_KEY` | [https://app.amplitude.com/analytics/settings/projects](https://app.amplitude.com/analytics/settings/projects) → your project → API key. Publishable. |
Dashboards: the events stream at [app.amplitude.com](https://app.amplitude.com) → Data → Events (or Analytics → User Look-Up for one user) · [project settings and API key](https://app.amplitude.com/analytics/settings/projects). Deps: `@amplitude/analytics-react-native ^1.8`, which pulls `@amplitude/analytics-core` (async-storage comes along as a transitive dep but is unused).
## Usage [#usage]
Track an event and identify the user:
```ts
import { analytics } from "@/lib/analytics";
analytics.track("checkout_started", { plan: "pro" });
analytics.identify(user.id, { email: user.email });
```
Reset on sign-out, which gives you a new device id and an anonymous user:
```ts
import { analytics } from "@/lib/analytics";
analytics.reset();
```
Screens are already tracked for you by the root layout, but you can send one by hand:
```ts
import { analytics } from "@/lib/analytics";
analytics.screen("/checkout");
```
## Consent [#consent]
`init` runs only on the first "yes" to analytics consent, with `optOut: false` and `trackingOptions: { ipAddress: false, adid: false, carrier: false }`; before that nothing is initialised or sent. Withdrawing consent calls `setOptOut(true)`, consent again `setOptOut(false)`. With `consent/consent` that means nothing runs in the EU/EEA, UK, CH, CA, BR until the sheet is answered, it runs elsewhere, and the Settings → Privacy toggle flips it live. `isAnalyticsConfigured` only means the key is set; `isAnalyticsActive()` tells you whether the SDK is running. `analytics.reset()` sets the user id to `undefined` and issues a new device id, leaving `optOut` alone - the root layout's `useSignOutCleanup` calls it through `wipeLocalData()` on every sign-out, and on account deletion.
Privacy declarations: UserID and CoarseLocation are declared `linked: true` - `identify` links them to the user, and Amplitude derives a coarse location server-side.
## Feature flags [#feature-flags]
`analytics.isFeatureEnabled` / `getFeatureFlag` / `useFeatureFlag` exist for parity with PostHog but always return `undefined` here: Amplitude Analytics has no flags, Amplitude Experiment is a separate SDK (`@amplitude/experiment-react-native-client`). Gate on `=== true` so code stays correct across providers.
## Tracking (ATT) [#tracking-att]
The module installs `expo-tracking-transparency` with a purpose string in `app.config.ts` (`NSUserTrackingUsageDescription`) and ships `src/lib/tracking.ts` - the same `tracking.getStatus() / requestPermission()` + `useTrackingStatus()` surface as the core shim. First-party product analytics is **not** tracking, so `features.tracking` defaults to `false` in `readynative.config.ts`: nothing ever prompts and you answer "No" to tracking in App Store Connect. Flip it to `true` when you link data across companies (ads attribution, IDFA): `AnalyticsProvider` then asks once the app is in the foreground (iOS answers `denied` silently if asked from the background) and `analytics.identify` waits for `granted`. To pitch the value first, remove that call from the provider and call `tracking.requestPermission()` behind your own explainer screen. `app.config.ts` adds the `expo-tracking-transparency` plugin only when `features.tracking` is `true`. With it `false` the plugin is dropped, `com.google.android.gms.permission.AD_ID` goes into `android.blockedPermissions`, and the privacy manifest gets `NSPrivacyTracking: false`; the `NSUserTrackingUsageDescription` string stays, because the library is still linked and App Store Connect flags a linked ATT framework without one (ITMS-90683). `doctor --store` reports the flag/library state either way.
## Gotchas [#gotchas]
* Storage `reset()` is a no-op, because the adapter can't enumerate keys. Only Amplitude's disabled legacy migration calls it.
* `bun run setup --analytics none` removes everything; with `--with-examples`, `/examples/analytics` then shows "Module not installed".
* Expo Go works, but without the optional native module there are no `Application Installed/Updated` events and no `idfv` or app-set-id. Make a dev build for the full context.
Check it works (the Examples steps need a tree set up with `--with-examples`):
1. Put a real `EXPO_PUBLIC_AMPLITUDE_KEY` in `.env`, run `bun run start -- -c`, and open the app on a device (Expo Go is fine).
2. Amplitude → Analytics → User Look-Up → search by device, or wait for `[Amplitude] Session Start`: a user appears within about a minute.
3. Examples → "Analytics event" → tap **Track event** → `example_pressed` with `source=examples/analytics` shows in that user's event stream. The SDK flushes every second or 30 events.
4. Tap **Identify user**: the user gets `user_id=demo-user` and the user property `plan=free`.
5. Navigate between tabs and watch one `[Amplitude] Screen Viewed` arrive per pathname.
6. Remove the key and restart: the example shows "Configure EXPO\_PUBLIC\_AMPLITUDE\_KEY" and the app still boots.
## Remove it [#remove-it]
While `modules/` exists (a tree set up with `--keep-modules`), `setup` does all of it:
bun run setup --analytics none --yes --keep-modules
In a finalized tree `setup` is a stub, so you undo it by hand. Here is everything this module added:
1. **Delete the files** that are still there (the demo screens are gone already unless you set up with `--with-examples`): `src/app/examples/analytics.tsx`, `src/lib/__tests__/analytics-amplitude.test.ts`, `src/lib/__tests__/tracking.test.ts`, `src/screens/examples/analytics-example-screen.tsx`.
2. **Replace, don't delete** `src/lib/analytics.ts`, `src/lib/tracking.ts`: core code imports them, so swap in the no-op version from `modules/analytics/none/files/` of a fresh clone of your tier repo - same exports, nothing behind them.
3. **Uninstall the dependencies**: `bun remove @amplitude/analytics-react-native expo-tracking-transparency`.
4. **Drop the config plugin** `expo-tracking-transparency` from `.readynative.json` → `modules.app.expo.plugins` (that is where `app.config.ts` reads it from), then rebuild the dev build.
5. **Unwrap the provider**: delete `` and its import from `src/providers.tsx`.
6. **Remove the env keys** `EXPO_PUBLIC_AMPLITUDE_KEY` from `.env`, `.env.example` and your EAS environment, and `EXPO_PUBLIC_AMPLITUDE_KEY` from `src/lib/env.ts`.
7. **Update the privacy declarations**: remove this module's entries from `.readynative.json` → `modules.app.expo.ios.privacyManifests`, then re-run `bun run gen:privacy` and revise your store privacy answers.
8. **Check it**: `bun run typecheck` and `bun run lint` point at anything that still imports the removed files; `bun run gen:graph` refreshes `docs/ARCHITECTURE.md`.
## Reference [#reference]
Everything below is generated from `modules/analytics/amplitude/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --analytics amplitude
Module id: `analytics/amplitude`.
### Dependencies [#dependencies]
| Package | Version | Kind |
| ----------------------------------- | --------- | --------------------------- |
| `@amplitude/analytics-react-native` | `^1.8.0` | dependency |
| `expo-tracking-transparency` | `~57.0.2` | dependency (`expo install`) |
### Config plugins [#config-plugins]
Merged into `app.config.ts` through `.readynative.json` (`modules.app`):
* `expo-tracking-transparency` (with options)
### Environment keys [#environment-keys]
| Key | Required | Server-only | Example | Docs |
| --------------------------- | -------- | ----------- | ---------------------------------- | ------------------------------------------------------------------ |
| `EXPO_PUBLIC_AMPLITUDE_KEY` | yes | no | `0123456789abcdef0123456789abcdef` | [dashboard](https://app.amplitude.com/analytics/settings/projects) |
Keys go in `.env` (see `.env.example`). `Required` keys are checked by `bun run doctor`; `Server-only` keys have no `EXPO_PUBLIC_` prefix, are read only by API routes and never reach the bundle.
### Privacy [#privacy]
Play Data safety draft: collects *App interactions (events, screens)*, *Device or other ids (device id)*, *User IDs (when you call analytics.identify)*, *Approximate location (derived from the request IP)*; shared with Amplitude (processor). Source of truth: [vendor disclosure](https://amplitude.com/privacy).
Apple privacy manifest data types (composed into `ios.privacyManifests` by setup):
| Type | Linked to user | Tracking | Purposes |
| -------------------- | -------------- | -------- | --------- |
| `ProductInteraction` | yes | no | Analytics |
| `DeviceID` | yes | no | Analytics |
| `UserID` | yes | no | Analytics |
| `CoarseLocation` | yes | no | Analytics |
### Providers [#providers]
Rendered in `src/providers.tsx` (lower order = outermost):
| Order | Provider | From |
| ----- | ------------------- | ----------------- |
| 40 | `AnalyticsProvider` | `@/lib/analytics` |
### Compatibility [#compatibility]
* Requires `storage` = [`kv-store`](https://readynative.app/docs/features/storage/kv-store), [`mmkv`](https://readynative.app/docs/features/storage/mmkv), [`async-storage`](https://readynative.app/docs/features/storage/async-storage)
### Doctor checks [#doctor-checks]
* env: `EXPO_PUBLIC_AMPLITUDE_KEY`
### After setup [#after-setup]
1. Amplitude: create a project at [https://app.amplitude.com](https://app.amplitude.com) (Settings > Projects), copy its API key into EXPO\_PUBLIC\_AMPLITUDE\_KEY.
2. Amplitude: open Data > Events (or User Look-Up), call `analytics.track("hello")` from any screen (or open /examples/analytics with `--with-examples`) and watch it arrive.
3. Amplitude: install/update lifecycle events and idfv/app-set-id need the SDK's native module - make a dev build (`npx expo run:ios`); Expo Go tracks everything else.
4. App Tracking Transparency: expo-tracking-transparency is installed, but app.config.ts only adds its plugin, the NSUserTrackingUsageDescription purpose string and NSPrivacyTracking: true when `features.tracking` is true in readynative.config.ts - set it only if you link data across companies (ads attribution, IDFA); the prompt then runs once the app is active and `analytics.identify` waits for it. With it off (the default) Android's AD\_ID permission is blocked and you answer 'No' to tracking in App Store Connect.
### Files [#files]
6 files copied to the project root
* `src/app/examples/analytics.tsx`
* `src/lib/__tests__/analytics-amplitude.test.ts`
* `src/lib/__tests__/tracking.test.ts`
* `src/lib/analytics.ts`
* `src/lib/tracking.ts`
* `src/screens/examples/analytics-example-screen.tsx`
# Analytics (/docs/features/analytics)
{/* Generated by apps/docs/scripts/gen-modules.ts; the intro is modules/analytics/README.md - do not edit. */}
Pro
Hey - here's how analytics work in ReadyNative.
This category owns product events: the three calls you make from screens, and the automatic screen event the root layout sends on every route change.
`@/lib/analytics` always exists, whichever option you pick: `analytics.track()`, `analytics.identify()` and `analytics.screen()` read the same in every one of them, and `analytics/none` is a no-op. Calls you write against the shim keep working when you switch options, and nothing crashes when the key is missing - every option degrades to a no-op.
Pick **PostHog** if you want events and feature flags from one service with a generous free tier, and the option to self-host later; it's the default here. Pick **Amplitude** if your team already lives in its funnels and cohorts, or you have an existing project to send to.
This category is part of ReadyNative Pro.
## Options [#options]
One option per category - `--analytics ` picks it:
bun run setup --analytics posthog
| Option | Label | Expo Go | Hint |
| -------------------------- | ----------------- | ------- | -------------------------------------------------------------------------------------------- |
| [`posthog`](./posthog) | PostHog (default) | yes | product analytics + feature flags (analytics.isFeatureEnabled / useFeatureFlag) · Expo Go OK |
| [`amplitude`](./amplitude) | Amplitude | yes | product analytics · Expo Go OK (JS SDK, storage adapter) |
| [`none`](./none) | No analytics | yes | analytics.track/identify/screen are no-ops, feature flags read undefined |
# No analytics (/docs/features/analytics/none)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/analytics/none - do not edit. */}
Pro
Runs in Expo Go; no dev build needed for this module.
This is the no-op analytics option: no SDK, no network calls, no third-party account. It owns `src/lib/analytics.ts` as a re-export of the core shim, so `@/lib/analytics` keeps the same API whichever option you pick - `analytics.track`, `analytics.identify` and `analytics.screen` all do nothing. Pick this while you're still building, or when you'd rather not collect anything at all.
## Setup [#setup]
```bash
bun run setup --analytics none
```
There's nothing to configure and no keys to paste. The root layout still calls `analytics.screen(pathname)` on every route change; it goes nowhere.
When you want the numbers, switch with `bun run setup --analytics posthog` or `--analytics amplitude`.
## Usage [#usage]
The calls exist and are safe to write today, so screens don't need rewriting when you switch:
```ts
import { analytics } from "@/lib/analytics";
analytics.track("checkout_started", { plan: "pro" }); // no-op
analytics.identify(user.id, { email: user.email }); // no-op
analytics.isFeatureEnabled("new-paywall"); // undefined - gate on `=== true`
```
## Gotchas [#gotchas]
* Events sent while this option is selected are gone - nothing is buffered and replayed when you switch.
* Nothing here needs a dev build; Expo Go is fine.
## Reference [#reference]
Everything below is generated from `modules/analytics/none/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --analytics none
Module id: `analytics/none`.
### Files [#files]
2 files copied to the project root
* `src/lib/analytics.ts`
* `src/lib/tracking.ts`
# PostHog (/docs/features/analytics/posthog)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/analytics/posthog - do not edit. */}
Pro
Runs in Expo Go; no dev build needed for this module.
This sends product analytics to PostHog: `src/lib/analytics.ts` builds a `posthog-react-native` 4.x client once analytics consent is given and implements `analytics.track/identify/screen`, with an `AnalyticsProvider` (order 40) that follows consent and asks for ATT when enabled. `PostHogProvider` is not mounted - it needs a client at mount, and swapping one in after consent would remount the app - so `usePostHog()` and posthog-react-native's own hooks don't work; use `analytics.*`, `useFeatureFlag`, or `getPostHog()` / `usePostHogClient()` for the raw client (both `undefined` until consent). The root layout already calls `analytics.screen(pathname)` on every route change, so screen autocapture is off and touch autocapture is off too. If you set up with `--with-examples` there's an example at `/examples/analytics` with a list of the last events sent. `@/lib/analytics` keeps the same API whichever option you pick - pick PostHog when you want events, feature flags and a generous free tier from one service you can also self-host.
## Setup [#setup]
```bash
bun run setup --analytics posthog
```
1. Create a project at [app.posthog.com](https://app.posthog.com), then open \[Settings] → \[Project] and copy the Project API key (`phc_…`) into `.env` as `EXPO_PUBLIC_POSTHOG_KEY`. It's publishable and safe in the client. Without it every call is a no-op and the example shows "Configure EXPO\_PUBLIC\_POSTHOG\_KEY".
2. If your project lives in the EU, set `EXPO_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com` in `.env`. It's optional and defaults to `https://us.i.posthog.com`.
3. Restart the dev server with `bun run start -- -c` so the new env vars are picked up, then open \[Activity] → \[Live events] in PostHog and watch for `Application Opened` and `$screen`.
4. Persistence goes through the storage adapter (`customStorage: storage` from `@/lib/storage`), not `expo-file-system`, so it follows your storage module. `expo-application`, `expo-device` and `expo-localization` are installed to fill in `$app_version`, `$device_name` and `$locale`.
Use a separate PostHog project for production so your own test events don't skew the funnels, and swap the key per build profile (`eas.json` `env`). The key is public, so there's nothing to keep secret - don't point release builds at your dev project.
5. Run `bun run doctor` - every row for this module should be green.
The keys, in one place:
| Key | Where to get it |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `EXPO_PUBLIC_POSTHOG_KEY` | [https://app.posthog.com/settings/project](https://app.posthog.com/settings/project) → Project API key (`phc_…`). Publishable. |
| `EXPO_PUBLIC_POSTHOG_HOST` | optional; `https://us.i.posthog.com` (default) or `https://eu.i.posthog.com` for EU projects |
Dashboards: [Live events](https://app.posthog.com/activity/explore) · [People](https://app.posthog.com/persons) · [Project settings](https://app.posthog.com/settings/project). Deps: `posthog-react-native ^4.71`, `expo-application`, `expo-device`, `expo-localization`. Expo Go works, since it's all JS.
## Usage [#usage]
Track an event and identify the user:
```ts
import { analytics } from "@/lib/analytics";
analytics.track("checkout_started", { plan: "pro" });
analytics.identify(user.id, { email: user.email });
```
Reset on sign-out, so the next person isn't the same person:
```ts
import { analytics } from "@/lib/analytics";
analytics.reset();
```
Feature flags are on the contract, so feature code stays provider-agnostic:
```tsx
import { analytics, useFeatureFlag } from "@/lib/analytics";
// Reactive: `undefined` until flags load, then the boolean or variant key.
const paywall = useFeatureFlag("new-paywall");
if (paywall === true) return ;
// One-shot, outside React:
if (analytics.isFeatureEnabled("new-paywall")) analytics.track("paywall_variant", { v: "new" });
const copy = analytics.getFeatureFlag("cta-copy"); // "variant-b" | true | false | undefined
```
Flags load after boot and after every `identify`; `useFeatureFlag` re-renders on each load. Payloads and forced reloads use the raw client: `getPostHog()?.getFeatureFlagPayload("flag")`, `getPostHog()?.reloadFeatureFlagsAsync()`. With `--analytics none` or `amplitude` every flag reads `undefined`, so gate on `=== true`.
## Consent [#consent]
No PostHog client exists until `consent.get().analytics` is true. The consent store is loaded when `@/lib/consent` is imported and the module follows it from import time on, so no event, `/flags` or remote-config request leaves the device before consent. On "yes" the client is created with `defaultOptIn: false` and `optIn()` is called explicitly; withdrawing calls `optOut()` and detaches the client (every call is then a no-op); consent again re-attaches the same client - a second one is never constructed. With `consent/consent` that means: nothing runs in the EU/EEA, UK, CH, CA, BR until the sheet is answered, it runs elsewhere, and the Settings → Privacy toggle flips it live. With `consent/none` it reads `true`. `isAnalyticsConfigured` only means the key is set.
`analytics.reset()` with a live client calls `reset([InstalledAppBuild, InstalledAppVersion])`, so both the anonymous id and the device id are regenerated, then re-applies consent (`optIn` / `optOut`) because PostHog's reset clears its persisted opt-out. With no live client it deletes the stored identity (storage key `.posthog-rn.json`), and a detached client is reset when consent comes back. The root layout's `useSignOutCleanup` calls `wipeLocalData()` - which calls `analytics.reset()` - on every sign-out, not only the Settings button, and on account deletion.
Privacy declarations: ProductInteraction, DeviceID, UserID and CoarseLocation, all `linked: true` - `identify` links them to the user and PostHog's GeoIP gives a coarse location.
## Tracking (ATT) [#tracking-att]
The module installs `expo-tracking-transparency` with a purpose string in `app.config.ts` (`NSUserTrackingUsageDescription`) and ships `src/lib/tracking.ts` - the same `tracking.getStatus() / requestPermission()` + `useTrackingStatus()` surface as the core shim. First-party product analytics is **not** tracking, so `features.tracking` defaults to `false` in `readynative.config.ts`: nothing ever prompts and you answer "No" to tracking in App Store Connect. Flip it to `true` when you link data across companies (ads attribution, IDFA): `AnalyticsProvider` then asks once the app is in the foreground (iOS answers `denied` silently if asked from the background) and `analytics.identify` waits for `granted`. To pitch the value first, remove that call from the provider and call `tracking.requestPermission()` behind your own explainer screen. `app.config.ts` adds the `expo-tracking-transparency` plugin only when `features.tracking` is `true`. With it `false` the plugin is dropped, `com.google.android.gms.permission.AD_ID` goes into `android.blockedPermissions`, and the privacy manifest gets `NSPrivacyTracking: false`; the `NSUserTrackingUsageDescription` string stays, because the library is still linked and App Store Connect flags a linked ATT framework without one (ITMS-90683). `doctor --store` reports the flag/library state either way.
## Gotchas [#gotchas]
* Session replay and surveys aren't enabled - they need extra native packages and a dev build.
* `bun run setup --analytics none` removes everything; with `--with-examples`, `/examples/analytics` then shows "Module not installed".
Check it works (the Examples steps need a tree set up with `--with-examples`):
1. Put a real `EXPO_PUBLIC_POSTHOG_KEY` in `.env`, run `bun run start -- -c`, and open the app on a device (Expo Go is fine).
2. Open PostHog → Activity → Live events and wait for `Application Opened` and a `$screen` for `/`.
3. Examples → "Analytics event" → tap **Track event** → `example_pressed` with `source=examples/analytics` appears in Live events. The SDK flushes every 10 seconds or 20 events, so background the app to force a flush.
4. Tap **Identify user**, then search People for `demo-user`: the person exists with `plan=free`.
5. Navigate between tabs and watch one `$screen` event arrive per pathname.
6. Remove the key and restart: the example shows "Configure EXPO\_PUBLIC\_POSTHOG\_KEY" and the app still boots.
## Remove it [#remove-it]
While `modules/` exists (a tree set up with `--keep-modules`), `setup` does all of it:
bun run setup --analytics none --yes --keep-modules
In a finalized tree `setup` is a stub, so you undo it by hand. Here is everything this module added:
1. **Delete the files** that are still there (the demo screens are gone already unless you set up with `--with-examples`): `src/app/examples/analytics.tsx`, `src/lib/__tests__/analytics-posthog.test.ts`, `src/lib/__tests__/tracking.test.ts`, `src/screens/examples/analytics-example-screen.tsx`.
2. **Replace, don't delete** `src/lib/analytics.ts`, `src/lib/tracking.ts`: core code imports them, so swap in the no-op version from `modules/analytics/none/files/` of a fresh clone of your tier repo - same exports, nothing behind them.
3. **Uninstall the dependencies**: `bun remove expo-application expo-localization expo-tracking-transparency posthog-react-native`. Keep any of `expo-localization` (also used by `i18n/i18next`, `i18n/lingui`, `consent/consent`) that another module you picked still needs.
4. **Drop the config plugin** `expo-tracking-transparency` from `.readynative.json` → `modules.app.expo.plugins` (that is where `app.config.ts` reads it from), then rebuild the dev build.
5. **Unwrap the provider**: delete `` and its import from `src/providers.tsx`.
6. **Remove the env keys** `EXPO_PUBLIC_POSTHOG_KEY`, `EXPO_PUBLIC_POSTHOG_HOST` from `.env`, `.env.example` and your EAS environment, and `EXPO_PUBLIC_POSTHOG_KEY`, `EXPO_PUBLIC_POSTHOG_HOST` from `src/lib/env.ts`.
7. **Update the privacy declarations**: remove this module's entries from `.readynative.json` → `modules.app.expo.ios.privacyManifests`, then re-run `bun run gen:privacy` and revise your store privacy answers.
8. **Check it**: `bun run typecheck` and `bun run lint` point at anything that still imports the removed files; `bun run gen:graph` refreshes `docs/ARCHITECTURE.md`.
## Reference [#reference]
Everything below is generated from `modules/analytics/posthog/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --analytics posthog
Module id: `analytics/posthog` (the default for this category).
### Dependencies [#dependencies]
| Package | Version | Kind |
| ---------------------------- | --------- | --------------------------- |
| `expo-application` | `~57.0.3` | dependency (`expo install`) |
| `expo-device` | `~57.0.2` | dependency (`expo install`) |
| `expo-localization` | `~57.0.2` | dependency (`expo install`) |
| `expo-tracking-transparency` | `~57.0.2` | dependency (`expo install`) |
| `posthog-react-native` | `^4.75.0` | dependency |
### Config plugins [#config-plugins]
Merged into `app.config.ts` through `.readynative.json` (`modules.app`):
* `expo-tracking-transparency` (with options)
### Environment keys [#environment-keys]
| Key | Required | Server-only | Example | Docs |
| -------------------------- | -------- | ----------- | ------------------------------------------------- | ----------------------------------------------------- |
| `EXPO_PUBLIC_POSTHOG_KEY` | yes | no | `phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | [dashboard](https://app.posthog.com/settings/project) |
| `EXPO_PUBLIC_POSTHOG_HOST` | no | no | `https://us.i.posthog.com` | [dashboard](https://app.posthog.com/settings/project) |
Keys go in `.env` (see `.env.example`). `Required` keys are checked by `bun run doctor`; `Server-only` keys have no `EXPO_PUBLIC_` prefix, are read only by API routes and never reach the bundle.
### Privacy [#privacy]
Play Data safety draft: collects *App interactions (events, screens)*, *Device or other ids (distinct id, device id)*, *User IDs (when you call analytics.identify)*, *Approximate location (GeoIP from the request IP)*; shared with PostHog (processor). Source of truth: [vendor disclosure](https://posthog.com/docs/privacy).
Apple privacy manifest data types (composed into `ios.privacyManifests` by setup):
| Type | Linked to user | Tracking | Purposes |
| -------------------- | -------------- | -------- | --------- |
| `ProductInteraction` | yes | no | Analytics |
| `DeviceID` | yes | no | Analytics |
| `UserID` | yes | no | Analytics |
| `CoarseLocation` | yes | no | Analytics |
### Providers [#providers]
Rendered in `src/providers.tsx` (lower order = outermost):
| Order | Provider | From |
| ----- | ------------------- | ----------------- |
| 40 | `AnalyticsProvider` | `@/lib/analytics` |
### Compatibility [#compatibility]
* Requires `storage` = [`kv-store`](https://readynative.app/docs/features/storage/kv-store), [`mmkv`](https://readynative.app/docs/features/storage/mmkv), [`async-storage`](https://readynative.app/docs/features/storage/async-storage)
### Doctor checks [#doctor-checks]
* env: `EXPO_PUBLIC_POSTHOG_KEY`
### After setup [#after-setup]
1. PostHog: create a project at [https://app.posthog.com](https://app.posthog.com), copy the Project API key into EXPO\_PUBLIC\_POSTHOG\_KEY.
2. PostHog: EU cloud? set EXPO\_PUBLIC\_POSTHOG\_HOST=[https://eu.i.posthog.com](https://eu.i.posthog.com) (default is the US cloud).
3. PostHog: open Activity > Live events, call `analytics.track("hello")` from any screen (or open /examples/analytics with `--with-examples`) and watch it arrive.
4. App Tracking Transparency: expo-tracking-transparency is installed, but app.config.ts only adds its plugin, the NSUserTrackingUsageDescription purpose string and NSPrivacyTracking: true when `features.tracking` is true in readynative.config.ts - set it only if you link data across companies (ads attribution, IDFA); the prompt then runs once the app is active and `analytics.identify` waits for it. With it off (the default) Android's AD\_ID permission is blocked and you answer 'No' to tracking in App Store Connect.
### Files [#files]
6 files copied to the project root
* `src/app/examples/analytics.tsx`
* `src/lib/__tests__/analytics-posthog.test.ts`
* `src/lib/__tests__/tracking.test.ts`
* `src/lib/analytics.ts`
* `src/lib/tracking.ts`
* `src/screens/examples/analytics-example-screen.tsx`
# Better Auth (/docs/features/auth/better-auth)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/auth/better-auth - do not edit. */}
Pro
Runs in Expo Go; no dev build needed for this module.
This is self-hosted auth: no third-party dashboard, no per-user pricing, and the server lives in your own tree at `src/server/auth.ts`, mounted at `/api/auth/*` through `src/app/api/auth/[...all]+api.ts`. It ships email/password plus Apple and Google (each enabled only when its keys are set), the screens `src/app/(auth)/{sign-in,sign-up,reset}` with `src/screens/auth/*`, and `src/hooks/use-auth-redirect.ts` (signed-out → `/(auth)/sign-in`, signed-in inside `(auth)` → `/`). It requires `backend/api-routes`, since it needs somewhere to run. `@/lib/auth` keeps the same API whichever option you pick - pick Better Auth when you want the user table to be yours.
## Setup [#setup]
```bash
bun run setup --auth better-auth --backend api-routes
```
1. Decide where the API routes are served and put that origin in `.env` as `EXPO_PUBLIC_API_URL`: `http://:8081` while `npx expo start` runs, or `https://.expo.app` after you deploy. Without it the module is disabled: always `unauthenticated`, no redirect, and sign-in shows "Configure EXPO\_PUBLIC\_API\_URL".
2. Generate a server secret with `openssl rand -base64 32` and put it in `.env` as `BETTER_AUTH_SECRET`, then set `BETTER_AUTH_URL` to the same origin you used in step 1. Both go under the `# server (never EXPO_PUBLIC)` heading in `.env.example`. Better Auth warns about a missing secret in dev and refuses to start in production without one.
3. Swap the database. The shipped server uses `better-auth/adapters/memory`, kept on `globalThis` because the Expo dev server re-evaluates the route per request: zero infra, but every user is gone on restart and it can't run on multi-instance hosting. Replace `database:` in `src/server/auth.ts` with a real adapter (`pg` Pool, Drizzle, Prisma or Kysely), put the connection string in `.env` as `DATABASE_URL`, and run `npx @better-auth/cli migrate`.
4. Plug an email provider into `src/server/email.ts` (see "Sending email" below). `sendResetPassword` in `src/server/auth.ts` sends through it; until you do, dev logs a notice without the address or the link and production throws, so password reset emails are never silently dropped.
5. For Apple sign-in, create a Services ID in the \[Apple Developer] portal → \[Certificates, Identifiers & Profiles] → \[Identifiers] → \[Services IDs], and a key to sign the client-secret JWT. Put them in `.env` as `APPLE_CLIENT_ID` and `APPLE_CLIENT_SECRET`, and add `${BETTER_AUTH_URL}/api/auth/callback/apple` as the redirect URI. Turn on the Sign in with Apple capability for your app's bundle id while you're in that portal.
6. For Google sign-in, create a **Web** OAuth client in \[Google Cloud Console] → \[APIs & Services] → \[Credentials], add `${BETTER_AUTH_URL}/api/auth/callback/google` as an authorized redirect URI, and put the pair in `.env` as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set the Android package name on the client if you build an Android release.
Deploy the API routes (EAS Hosting or your own host), point `EXPO_PUBLIC_API_URL` and `BETTER_AUTH_URL` at that https origin, and re-register every OAuth redirect URI against it. Set the server keys in EAS rather than in `.env`: `eas env:set --environment production --visibility sensitive` (EAS Hosting can't deploy `secret` variables). And do not ship the memory adapter.
7. Run `bun run doctor` - every row for this module should be green.
The keys, in one place:
| Key | Where | Get it |
| ------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `EXPO_PUBLIC_API_URL` | client | the API routes origin: `http://:8081` in dev, `https://.expo.app` after deploy |
| `BETTER_AUTH_SECRET` | server | `openssl rand -base64 32` - [https://www.better-auth.com/docs/installation](https://www.better-auth.com/docs/installation) |
| `BETTER_AUTH_URL` | server | same origin as `EXPO_PUBLIC_API_URL` |
| `DATABASE_URL` | server (after the swap) | your Postgres - [https://www.better-auth.com/docs/concepts/database](https://www.better-auth.com/docs/concepts/database) |
| `APPLE_CLIENT_ID` / `APPLE_CLIENT_SECRET` | server, optional | Services ID + key JWT - [https://developer.apple.com/account/resources/identifiers/list/serviceId](https://developer.apple.com/account/resources/identifiers/list/serviceId), [https://www.better-auth.com/docs/authentication/apple](https://www.better-auth.com/docs/authentication/apple) |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | server, optional | Web OAuth client - [https://console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials) |
Deps: `better-auth`, `@better-auth/expo`, `expo-secure-store` (config plugin), `expo-network`. Expo Go works. Jest gets root `__mocks__` for `better-auth/react`, `@better-auth/expo/client` and `expo-secure-store` (those packages are ESM-only or native), plus tests for the session mapping and the redirect hook.
## Usage [#usage]
Read the session anywhere:
```ts
import { auth } from "@/lib/auth";
const session = auth.useSession();
if (session.status === "authenticated") console.log(session.user.email);
```
Email sign-up and sign-in:
```ts
import { signUpWithEmail, signInWithEmail } from "@/lib/auth";
await signUpWithEmail(name, email, password);
await signInWithEmail(email, password);
```
Social sign-in and sign-out:
```ts
import { auth, signInWithProvider } from "@/lib/auth";
await signInWithProvider("google"); // or "apple"
await auth.signOut();
```
`auth.openSignIn()` opens `/(auth)/sign-in` from anywhere; Settings → Developer tools uses it. A signed-in user is sent straight back, so sign out first.
### Server routes [#server-routes]
API routes never trust a user id from the client. The app attaches credentials with `auth.getAuthHeaders()` (the session cookie Better Auth keeps in SecureStore, as a `cookie` header; `{}` while signed out), and the route resolves the caller with `serverAuth.getRequestUser(request)` from `src/server/session.ts` (`auth.api.getSession({ headers })` against Better Auth's own session table). It returns `null` for a missing or invalid credential.
```ts
import { auth } from "@/lib/auth";
const res = await fetch(url, { headers: await auth.getAuthHeaders() });
```
```ts
import { serverAuth } from "@/server/session";
const user = await serverAuth.getRequestUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
```
### Delete account [#delete-account]
Settings ships a confirm-guarded "Delete account" row (App Store Review 5.1.1(v), Play "Account deletion") that calls `auth.deleteAccount(options?: { password?: string })` → `authClient.deleteUser()`; `src/server/auth.ts` enables it with `user: { deleteUser: { enabled: true } }`, which is required. Better Auth 1.7 deletes immediately with a fresh session (`session.freshAge`, default 1 day, measured from session creation). The module sets it explicitly from `SESSION_FRESH_AGE_SECONDS` in `src/lib/auth-policy.ts`, shared by the server and the app, and `auth.prepareDeleteAccount()` applies the same rule to `authClient.getSession()` before Settings erases anything else (analytics, purchases), so a cancelled password prompt leaves everything as it was. A stale session (in the pre-flight, or `SESSION_EXPIRED` from the server) becomes `code: "REAUTH_REQUIRED"`; Settings then shows a "Confirm it's you" password sheet and retries with `{ password }` (a valid password skips the freshness check). `INVALID_PASSWORD` asks again; `CREDENTIAL_ACCOUNT_NOT_FOUND` (a social-only user has no password) maps to `SIGN_IN_AGAIN`, and Settings tells the user to sign in again first. Better Auth deletes the user and their sessions/accounts and the client store flips to signed-out. To confirm by email instead, set `sendDeleteAccountVerification` in `src/server/auth.ts` to confirm by email before `beforeDelete`/`afterDelete` run. Rows in your own tables that reference the user are yours to remove in `afterDelete`.
## Sending email [#sending-email]
`src/server/email.ts` exports `sendEmail({ to, subject, text, html? })`, the one place server code sends mail. It ships without a provider: replace `const deliver = null` with a function that calls yours. Resend (`bun add resend`, `RESEND_API_KEY` in `.env` as a server key):
```ts
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const deliver = async (email: Email) => {
const { error } = await resend.emails.send({ from: "App ", ...email });
if (error) throw new Error(`[email] Resend: ${error.message}`);
};
```
Postmark (`bun add postmark`, `POSTMARK_SERVER_TOKEN`):
```ts
import { ServerClient } from "postmark";
const postmark = new ServerClient(process.env.POSTMARK_SERVER_TOKEN ?? "");
const deliver = async (email: Email) => {
await postmark.sendEmail({
From: "noreply@your-domain.com",
To: email.to,
Subject: email.subject,
TextBody: email.text,
HtmlBody: email.html,
});
};
```
Both run on EAS Hosting (they only need `fetch`); verify your sending domain with the provider first, and set the key with `eas env:set --environment production --visibility sensitive`. Better Auth awaits the hook, so a provider error reaches the client as a failed "Send reset link" - it's logged server-side too. The reset link opens `${BETTER_AUTH_URL}/api/auth/reset-password/`, which redirects to `/?token=…` on that origin; the page that asks for the new password and calls `authClient.resetPassword({ newPassword, token })` is yours to add (a web route, or a deep link into the app).
## Gotchas [#gotchas]
* `EXPO_PUBLIC_API_URL` is shared with the data modules. When both are selected, the first module's example value wins in `.env.example`.
* Native Sign in with Apple (the `expo-apple-authentication` id-token flow) is not wired; the button uses the web OAuth flow through the system browser. `appBundleIdentifier` is already passed, so you can add the id-token flow client-side.
* Better Auth warns about a missing `BETTER_AUTH_SECRET` in dev and refuses to start in production without one.
Check it works (the Examples steps need a tree set up with `--with-examples`):
1. Set `EXPO_PUBLIC_API_URL=http://:8081`, `BETTER_AUTH_URL` to the same, and a random `BETTER_AUTH_SECRET`, then run `bun run start`.
2. `curl -s $BETTER_AUTH_URL/api/auth/ok` returns `{"ok":true}`.
3. The app opens on `/(auth)/sign-in` after onboarding. Tap "Create one" → name, email, password → you land on Home and Settings shows the Account card.
4. Examples → "Auth session" shows name, email and id; "Sign out" takes you back to sign-in.
5. Sign in with the same email and a wrong password → toast "Invalid email or password"; the correct one lands on Home. Kill and reopen the app → still signed in, from the SecureStore cookie.
6. Tap "Forgot password?" → enter the email → "Check your inbox"; with no email provider plugged in, the `npx expo start` terminal shows `[email] No email provider is configured` (never the link). With one, the email arrives.
7. With Apple or Google keys set, "Continue with Google" opens the browser and returns via `readynative://` with an authenticated session.
8. Restart the dev server → the users are gone. That's the memory adapter, and it's expected until you swap the database.
## Remove it [#remove-it]
While `modules/` exists (a tree set up with `--keep-modules`), `setup` does all of it:
bun run setup --auth none --yes --keep-modules
In a finalized tree `setup` is a stub, so you undo it by hand. Here is everything this module added:
1. **Delete the files** that are still there (the demo screens are gone already unless you set up with `--with-examples`): `__mocks__/@better-auth/expo/client.ts`, `__mocks__/better-auth/react.ts`, `__mocks__/expo-secure-store.ts`, `src/app/(auth)/_layout.tsx`, `src/app/(auth)/reset.tsx`, `src/app/(auth)/sign-in.tsx`, `src/app/(auth)/sign-up.tsx`, `src/app/api/auth/[...all]+api.ts`, `src/app/examples/auth.tsx`, `src/hooks/__tests__/use-auth-redirect.test.tsx`, `src/lib/__tests__/auth-better-auth-disabled.test.tsx`, `src/lib/__tests__/auth-better-auth.test.tsx`, `src/lib/auth-policy.ts`, `src/screens/auth/reset-screen.tsx`, `src/screens/auth/sign-in-screen.tsx`, `src/screens/auth/sign-up-screen.tsx`, `src/screens/examples/auth-example-screen.tsx`, `src/server/__tests__/email.test.ts`, `src/server/__tests__/session-better-auth.test.ts`, `src/server/auth.ts`, `src/server/email.ts`.
2. **Replace, don't delete** `src/hooks/use-auth-redirect.ts`, `src/lib/auth.ts`, `src/server/session.ts`: core code imports them, so swap in the no-op version from `modules/auth/none/files/` of a fresh clone of your tier repo - same exports, nothing behind them.
3. **Uninstall the dependencies**: `bun remove @better-auth/expo better-auth expo-network expo-secure-store`.
4. **Drop the config plugin** `expo-secure-store` from `.readynative.json` → `modules.app.expo.plugins` (that is where `app.config.ts` reads it from), then rebuild the dev build.
5. **Remove the env keys** `EXPO_PUBLIC_API_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DATABASE_URL`, `APPLE_CLIENT_ID`, `APPLE_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` from `.env`, `.env.example` and your EAS environment, and `EXPO_PUBLIC_API_URL` from `src/lib/env.ts`.
6. **Update the privacy declarations**: remove this module's entries from `.readynative.json` → `modules.app.expo.ios.privacyManifests`, then re-run `bun run gen:privacy` and revise your store privacy answers.
7. **Check it**: `bun run typecheck` and `bun run lint` point at anything that still imports the removed files; `bun run gen:graph` refreshes `docs/ARCHITECTURE.md`.
## Reference [#reference]
Everything below is generated from `modules/auth/better-auth/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --auth better-auth
Module id: `auth/better-auth`.
### Dependencies [#dependencies]
| Package | Version | Kind |
| ------------------- | --------- | --------------------------- |
| `@better-auth/expo` | `^1.7.5` | dependency |
| `better-auth` | `^1.7.5` | dependency |
| `expo-network` | `~57.0.2` | dependency (`expo install`) |
| `expo-secure-store` | `~57.0.4` | dependency (`expo install`) |
### Config plugins [#config-plugins]
Merged into `app.config.ts` through `.readynative.json` (`modules.app`):
* `expo-secure-store`
### Environment keys [#environment-keys]
| Key | Required | Server-only | Example | Docs |
| ---------------------- | -------- | ----------- | -------------------------------------- | ------------------------------------------------------------------------------------- |
| `EXPO_PUBLIC_API_URL` | no | no | `http://localhost:8081` | [dashboard](https://www.better-auth.com/docs/integrations/expo) |
| `BETTER_AUTH_SECRET` | yes | yes | `generate-with-openssl-rand-base64-32` | [dashboard](https://www.better-auth.com/docs/installation#set-environment-variables) |
| `BETTER_AUTH_URL` | yes | yes | `http://localhost:8081` | [dashboard](https://www.better-auth.com/docs/installation#set-environment-variables) |
| `DATABASE_URL` | no | yes | `postgres://user:pass@host:5432/app` | [dashboard](https://www.better-auth.com/docs/installation#configure-database) |
| `APPLE_CLIENT_ID` | no | yes | `com.acme.app.web` | [dashboard](https://developer.apple.com/account/resources/identifiers/list/serviceId) |
| `APPLE_CLIENT_SECRET` | no | yes | `eyJ...` | [dashboard](https://www.better-auth.com/docs/authentication/apple) |
| `GOOGLE_CLIENT_ID` | no | yes | `xxx.apps.googleusercontent.com` | [dashboard](https://console.cloud.google.com/apis/credentials) |
| `GOOGLE_CLIENT_SECRET` | no | yes | `GOCSPX-...` | [dashboard](https://console.cloud.google.com/apis/credentials) |
Keys go in `.env` (see `.env.example`). `Required` keys are checked by `bun run doctor`; `Server-only` keys have no `EXPO_PUBLIC_` prefix, are read only by API routes and never reach the bundle.
### Privacy [#privacy]
Play Data safety draft: collects *Email address, name (account)*, *Session tokens*; shared with Nobody - stored on your own backend. Source of truth: [vendor disclosure](https://www.better-auth.com/docs/concepts/database).
Apple privacy manifest data types (composed into `ios.privacyManifests` by setup):
| Type | Linked to user | Tracking | Purposes |
| -------------- | -------------- | -------- | ---------------- |
| `EmailAddress` | yes | no | AppFunctionality |
| `Name` | yes | no | AppFunctionality |
| `UserID` | yes | no | AppFunctionality |
### Compatibility [#compatibility]
* Requires `backend` = [`api-routes`](https://readynative.app/docs/features/backend/api-routes)
### Doctor checks [#doctor-checks]
* env: `EXPO_PUBLIC_API_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`
### After setup [#after-setup]
1. Better Auth: `openssl rand -base64 32` → BETTER\_AUTH\_SECRET in .env; BETTER\_AUTH\_URL = the API routes origin (http\://\:8081 in dev, the EAS Hosting URL in prod)
2. Better Auth: the shipped src/server/auth.ts uses the in-memory adapter (users vanish on restart) - swap it for Postgres/Drizzle/Prisma before shipping (docs.md)
3. Apple sign-in: create a Services ID (APPLE\_CLIENT\_ID) + key (APPLE\_CLIENT\_SECRET JWT) at developer.apple.com and add BETTER\_AUTH\_URL/api/auth/callback/apple as a return URL
4. Google sign-in: create a Web OAuth client at console.cloud.google.com with BETTER\_AUTH\_URL/api/auth/callback/google as an authorised redirect URI
5. Set the same server keys on EAS Hosting: `eas env:set --environment production --name BETTER_AUTH_SECRET --value … --visibility sensitive` (EAS Hosting can't deploy `secret` variables), then `eas deploy --environment production`
### Files [#files]
24 files copied to the project root
* `__mocks__/@better-auth/expo/client.ts`
* `__mocks__/better-auth/react.ts`
* `__mocks__/expo-secure-store.ts`
* `src/app/(auth)/_layout.tsx`
* `src/app/(auth)/reset.tsx`
* `src/app/(auth)/sign-in.tsx`
* `src/app/(auth)/sign-up.tsx`
* `src/app/api/auth/[...all]+api.ts`
* `src/app/examples/auth.tsx`
* `src/hooks/__tests__/use-auth-redirect.test.tsx`
* `src/hooks/use-auth-redirect.ts`
* `src/lib/__tests__/auth-better-auth-disabled.test.tsx`
* `src/lib/__tests__/auth-better-auth.test.tsx`
* `src/lib/auth-policy.ts`
* `src/lib/auth.ts`
* `src/screens/auth/reset-screen.tsx`
* `src/screens/auth/sign-in-screen.tsx`
* `src/screens/auth/sign-up-screen.tsx`
* `src/screens/examples/auth-example-screen.tsx`
* `src/server/__tests__/email.test.ts`
* `src/server/__tests__/session-better-auth.test.ts`
* `src/server/auth.ts`
* `src/server/email.ts`
* `src/server/session.ts`
# Clerk (/docs/features/auth/clerk)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/auth/clerk - do not edit. */}
Pro
Runs in Expo Go; no dev build needed for this module.
This wires Clerk into the app: email/password with a 6-digit code step, Sign in with Apple, Google SSO, and a hosted user database you never have to run. You get `src/app/(auth)/{sign-in,sign-up,reset}` with `src/screens/auth/*`, the redirect hook `src/hooks/use-auth-redirect.ts` (signed-out users land on `/(auth)/sign-in`, signed-in users leave `(auth)`, onboarding wins first), and, with `--with-examples`, an example at `src/app/examples/auth.tsx`. `@/lib/auth` keeps the same API whichever option you pick - pick Clerk when you want user management, organizations and a polished dashboard without owning a database.
## Setup [#setup]
```bash
bun run setup --auth clerk
```
1. Create an application at [clerk.com](https://clerk.com), then open \[Dashboard] → \[API keys]. Copy the publishable key (`pk_test_…`) into `.env` as `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`. Without it `ClerkProvider` is never mounted: `useSession()` stays `unauthenticated`, there's no redirect, and sign-in shows "Configure Clerk".
2. In Clerk, open \[User & Authentication] → \[Email, phone, username] and enable **Email address** and **Password**, with verification set to **Email verification code** - the shipped sign-up and reset screens expect the code step.
3. Open \[SSO connections] → \[Apple] and enable it. Add your iOS bundle id under Clerk's "Apple → Native" section so the native sheet is accepted.
4. Turn on the Sign in with Apple capability for that bundle id in the \[Apple Developer] portal → \[Certificates, Identifiers & Profiles] → \[Identifiers] → your app id.
5. Open \[SSO connections] → \[Google] and enable it. Development instances work with Clerk's shared credentials, so there's nothing to paste yet.
6. Open \[Native applications] and add the iOS bundle id and the Android package from `readynative.config.ts`, so Clerk accepts native requests from your app.
7. Screens call `useT()` with English keys. If you selected an i18n module and haven't added `ru` entries, they render in English - keys fall back to themselves.
Swap the key for the production instance's `pk_live_…`, and give Google your own OAuth client: create one in \[Google Cloud Console] → \[APIs & Services] → \[Credentials] and paste the client id and secret into Clerk's Google connection. Clerk's shared credentials are development-only.
8. If you use API routes that need the caller (payments/stripe), copy the **Secret key** from \[Dashboard] → \[API keys] into `.env` as `CLERK_SECRET_KEY`, under the `# server (never EXPO_PUBLIC)` heading - never with an `EXPO_PUBLIC_` prefix. Alternatively set `CLERK_JWT_KEY` to the JWT public key (PEM) from the same page to verify tokens without a network call. With neither set, `getRequestUser` throws. For EAS Hosting add it with `eas env:set --environment production --visibility sensitive` (`secret` variables don't deploy to EAS Hosting).
9. Run `bun run doctor` - every row for this module should be green.
The keys, in one place:
| Key | Where |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` | [Dashboard → API keys](https://dashboard.clerk.com/last-active?path=api-keys) (`pk_test_…`) |
| `CLERK_SECRET_KEY` (server) | [Dashboard → API keys](https://dashboard.clerk.com/last-active?path=api-keys) (`sk_test_…`); needed by API routes that verify the caller |
| `CLERK_JWT_KEY` (server, optional) | Dashboard → API keys → JWT public key (PEM); [networkless verification](https://clerk.com/docs/guides/sessions/manual-jwt-verification) instead of the secret key |
| `CLERK_WEBHOOK_SIGNING_SECRET` (server, optional) | Dashboard → Webhooks → your `/api/webhooks/clerk` endpoint → Signing secret (`whsec_…`); with `backend/api-routes`, for `user.deleted` |
Deps: `@clerk/expo ^4.6.6` (the maintained package; `@clerk/clerk-expo` is deprecated), `expo-secure-store`, `expo-auth-session`, `expo-apple-authentication`, `expo-crypto` (`expo-web-browser` is a core dep). Config plugins: `@clerk/expo`, `expo-apple-authentication`, `expo-secure-store`.
## Usage [#usage]
Read the session anywhere:
```ts
import { auth } from "@/lib/auth";
const session = auth.useSession();
if (session.status === "authenticated") console.log(session.user.email);
```
Sign up in two steps, because Clerk mails a code:
```ts
import { signUpWithEmail, verifySignUpCode } from "@/lib/auth";
const result = await signUpWithEmail(email, password);
if (result.status === "needs_verification") await verifySignUpCode(code);
```
Social sign-in and sign-out:
```ts
import { auth, signInWithApple, canSignInWithApple, signInWithGoogle } from "@/lib/auth";
if (canSignInWithApple) await signInWithApple();
await signInWithGoogle();
await auth.signOut();
```
`auth.openSignIn()` opens `/(auth)/sign-in` from anywhere; Settings → Developer tools uses it. A signed-in user is sent straight back, so sign out first.
### Server routes [#server-routes]
API routes never trust a user id from the client. The app attaches credentials with `auth.getAuthHeaders()` (`authorization: Bearer `; `{}` while signed out), and the route resolves the caller with `serverAuth.getRequestUser(request)` from `src/server/session.ts` (`verifyToken` from `@clerk/backend`, keyed by `CLERK_SECRET_KEY`, or networkless by `CLERK_JWT_KEY`). It returns `null` for a missing or invalid credential, and throws when neither `CLERK_SECRET_KEY` nor `CLERK_JWT_KEY` is set - answer 503 then.
```ts
import { auth } from "@/lib/auth";
const res = await fetch(url, { headers: await auth.getAuthHeaders() });
```
```ts
import { serverAuth } from "@/server/session";
const user = await serverAuth.getRequestUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
```
### Delete account [#delete-account]
Settings ships a confirm-guarded "Delete account" row (App Store Review 5.1.1(v), Play "Account deletion") that calls `auth.deleteAccount()` → `clerk.user.delete()`. Turn on **User & Authentication → Settings → Allow users to delete their accounts** in the Clerk dashboard first - with it off (`user.deleteSelfEnabled` is false) `deleteAccount()` throws a clear error saying so. Clerk ends the session itself once the user is gone; `useAuthRedirect` sends them to sign-in. Data you keep outside Clerk needs a `user.deleted` webhook: with `backend/api-routes`, `POST /api/webhooks/clerk` handles `user.deleted` and erases that user in the configured vendors. In the Clerk dashboard → \[Webhooks], add the endpoint `https:///api/webhooks/clerk` subscribed to `user.deleted`, and put its signing secret (`whsec_…`) in `.env` as `CLERK_WEBHOOK_SIGNING_SECRET` (server, never `EXPO_PUBLIC_`).
## Gotchas [#gotchas]
* Clerk's iOS SDK requires iOS 17; the config plugin raises the deployment target for dev builds.
* Sign-in steps that need a second factor (MFA) or extra fields surface as an error toast. If you enable those in the dashboard, add screens for them.
* Expo Go is fine for the flows shipped here: Clerk's native module is optional (`requireOptionalNativeModule`) and only backs Clerk's native views and biometrics, which this module doesn't use.
Check it works (the Examples steps need a tree set up with `--with-examples`):
1. Put the key in `.env`, run `npx expo start` (or a dev build), and open a fresh install: onboarding → `/(auth)/sign-in`.
2. Sign up with a new email → code step → enter the mailed code → you land on `/` and Settings shows the Account card with the email.
3. Settings → Sign out → back on sign-in.
4. Sign in with a wrong password → error toast with Clerk's message, no navigation.
5. On iOS, tap Continue with Apple → native sheet → signed in (the first time creates the user, the second signs in).
6. Tap Continue with Google → system browser → back in the app signed in.
7. Tap Forgot password → code step → code plus a new password → signed in with the new password.
8. Kill and reopen the app → still signed in, token read from secure store.
9. Examples → Auth session → the JSON shows `status: "authenticated"`.
## Remove it [#remove-it]
While `modules/` exists (a tree set up with `--keep-modules`), `setup` does all of it:
bun run setup --auth none --yes --keep-modules
In a finalized tree `setup` is a stub, so you undo it by hand. Here is everything this module added:
1. **Delete the files** that are still there (the demo screens are gone already unless you set up with `--with-examples`): `src/app/(auth)/_layout.tsx`, `src/app/(auth)/reset.tsx`, `src/app/(auth)/sign-in.tsx`, `src/app/(auth)/sign-up.tsx`, `src/app/examples/auth.tsx`, `src/lib/__tests__/auth-clerk.test.tsx`, `src/screens/auth/auth-form.ts`, `src/screens/auth/reset-screen.tsx`, `src/screens/auth/sign-in-screen.tsx`, `src/screens/auth/sign-up-screen.tsx`, `src/screens/examples/auth-example-screen.tsx`, `src/server/__tests__/session-clerk.test.ts`.
2. **Replace, don't delete** `src/hooks/use-auth-redirect.ts`, `src/lib/auth.ts`, `src/server/session.ts`: core code imports them, so swap in the no-op version from `modules/auth/none/files/` of a fresh clone of your tier repo - same exports, nothing behind them.
3. **Uninstall the dependencies**: `bun remove @clerk/backend @clerk/expo expo-apple-authentication expo-auth-session expo-crypto expo-secure-store`.
4. **Drop the config plugins** `@clerk/expo`, `expo-apple-authentication`, `expo-secure-store` from `.readynative.json` → `modules.app.expo.plugins` (that is where `app.config.ts` reads them from), then rebuild the dev build.
5. **Unwrap the provider**: delete `` and its import from `src/providers.tsx`.
6. **Remove the env keys** `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`, `CLERK_SECRET_KEY`, `CLERK_JWT_KEY` from `.env`, `.env.example` and your EAS environment, and `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` from `src/lib/env.ts`.
7. **Update the privacy declarations**: remove this module's entries from `.readynative.json` → `modules.app.expo.ios.privacyManifests`, then re-run `bun run gen:privacy` and revise your store privacy answers.
8. **Check it**: `bun run typecheck` and `bun run lint` point at anything that still imports the removed files; `bun run gen:graph` refreshes `docs/ARCHITECTURE.md`.
## Reference [#reference]
Everything below is generated from `modules/auth/clerk/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --auth clerk
Module id: `auth/clerk`.
### Dependencies [#dependencies]
| Package | Version | Kind |
| --------------------------- | ---------- | --------------------------- |
| `@clerk/backend` | `^3.19.0` | dependency |
| `@clerk/expo` | `^4.6.8` | dependency |
| `expo-apple-authentication` | `~57.0.2` | dependency (`expo install`) |
| `expo-auth-session` | `~57.0.12` | dependency (`expo install`) |
| `expo-crypto` | `~57.0.3` | dependency (`expo install`) |
| `expo-secure-store` | `~57.0.4` | dependency (`expo install`) |
### Config plugins [#config-plugins]
Merged into `app.config.ts` through `.readynative.json` (`modules.app`):
* `@clerk/expo`
* `expo-apple-authentication`
* `expo-secure-store`
### Environment keys [#environment-keys]
| Key | Required | Server-only | Example | Docs |
| ----------------------------------- | -------- | ----------- | ------------------------------- | --------------------------------------------------------------------------- |
| `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` | yes | no | `pk_test_...` | [dashboard](https://dashboard.clerk.com/last-active?path=api-keys) |
| `CLERK_SECRET_KEY` | no | yes | `sk_test_...` | [dashboard](https://dashboard.clerk.com/last-active?path=api-keys) |
| `CLERK_JWT_KEY` | no | yes | `-----BEGIN PUBLIC KEY-----...` | [dashboard](https://clerk.com/docs/guides/sessions/manual-jwt-verification) |
Keys go in `.env` (see `.env.example`). `Required` keys are checked by `bun run doctor`; `Server-only` keys have no `EXPO_PUBLIC_` prefix, are read only by API routes and never reach the bundle.
### Privacy [#privacy]
Play Data safety draft: collects *Email address, phone, name (account)*, *Session tokens*, *Device identifiers*; shared with Clerk (processor). Source of truth: [vendor disclosure](https://clerk.com/legal/privacy).
Apple privacy manifest data types (composed into `ios.privacyManifests` by setup):
| Type | Linked to user | Tracking | Purposes |
| -------------- | -------------- | -------- | ---------------- |
| `EmailAddress` | yes | no | AppFunctionality |
| `PhoneNumber` | yes | no | AppFunctionality |
| `Name` | yes | no | AppFunctionality |
| `UserID` | yes | no | AppFunctionality |
| `DeviceID` | yes | no | AppFunctionality |
### Providers [#providers]
Rendered in `src/providers.tsx` (lower order = outermost):
| Order | Provider | From |
| ----- | -------------- | ------------ |
| 30 | `AuthProvider` | `@/lib/auth` |
### Compatibility [#compatibility]
* Requires `storage` = [`kv-store`](https://readynative.app/docs/features/storage/kv-store), [`mmkv`](https://readynative.app/docs/features/storage/mmkv), [`async-storage`](https://readynative.app/docs/features/storage/async-storage)
### Doctor checks [#doctor-checks]
* env: `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`
### After setup [#after-setup]
1. Clerk: create an application, copy the Publishable key (API keys) into .env as EXPO\_PUBLIC\_CLERK\_PUBLISHABLE\_KEY.
2. Clerk: API routes that need the caller (payments/stripe) verify the session token with @clerk/backend - copy the Secret key into .env as CLERK\_SECRET\_KEY (server-only; for EAS Hosting also `eas env:set --environment production --visibility sensitive`).
3. Clerk: User & Authentication → Email → enable Email address + Password + Email verification code.
4. Clerk: User & Authentication → Settings → enable "Allow users to delete their accounts" (Settings → Delete account calls user.delete()).
5. Clerk: SSO connections → Apple: add the iOS bundle id (native Sign in with Apple); Google: add your own OAuth client for production.
6. Clerk: Native applications → add the iOS bundle id / Android package so native flows are allowed.
7. Apple: enable the Sign in with Apple capability for the bundle id (EAS does it on the first build).
8. Dev builds: the @clerk/expo plugin raises the iOS deployment target to 17.0.
### Files [#files]
15 files copied to the project root
* `src/app/(auth)/_layout.tsx`
* `src/app/(auth)/reset.tsx`
* `src/app/(auth)/sign-in.tsx`
* `src/app/(auth)/sign-up.tsx`
* `src/app/examples/auth.tsx`
* `src/hooks/use-auth-redirect.ts`
* `src/lib/__tests__/auth-clerk.test.tsx`
* `src/lib/auth.ts`
* `src/screens/auth/auth-form.ts`
* `src/screens/auth/reset-screen.tsx`
* `src/screens/auth/sign-in-screen.tsx`
* `src/screens/auth/sign-up-screen.tsx`
* `src/screens/examples/auth-example-screen.tsx`
* `src/server/__tests__/session-clerk.test.ts`
* `src/server/session.ts`
# Auth (/docs/features/auth)
{/* Generated by apps/docs/scripts/gen-modules.ts; the intro is modules/auth/README.md - do not edit. */}
Pro
Hey - here's how auth works in ReadyNative.
This category owns everything about the signed-in user: the sign-in, sign-up and reset screens under `src/app/(auth)`, the redirect hook that decides where a signed-out person lands, and the Account card in Settings.
`@/lib/auth` always exists, whichever option you pick: `auth.useSession()` and `auth.signOut()` read the same in every one of them, and `auth/none` is a no-op that reports a signed-out session. Screens you write against the shim keep working when you switch options.
Pick **Supabase** if you also want a Postgres database and row-level security behind the same account - it's the cheapest path from zero to a real backend. Pick **Clerk** if you'd rather buy user management outright: hosted accounts, a good dashboard, organizations, and a code-based sign-up flow that's already wired here. Pick **Better Auth** if the user table has to be yours - it runs on your own API routes (`backend/api-routes` is required), with no third-party dashboard and no per-user pricing, in exchange for owning the database and the mail provider.
This category is part of ReadyNative Pro.
## Options [#options]
One option per category - `--auth ` picks it:
bun run setup --auth supabase
| Option | Label | Expo Go | Hint |
| ------------------------------ | ----------------------- | ------- | --------------------------------------------------------------------------------------------- |
| [`supabase`](./supabase) | Supabase Auth (default) | yes | email+password, Apple (native), Google (OAuth) · session on the storage adapter · Expo Go OK |
| [`clerk`](./clerk) | Clerk | yes | email+code verification, Apple (native), Google (SSO) · @clerk/expo · Expo Go OK for JS flows |
| [`better-auth`](./better-auth) | Better Auth | yes | self-hosted on the API routes · email/password + Apple/Google · Expo Go OK |
| [`none`](./none) | No auth | yes | auth.useSession() shim returns signed-out, signOut resolves |
# No auth (/docs/features/auth/none)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/auth/none - do not edit. */}
Pro
Runs in Expo Go; no dev build needed for this module.
This is the no-op auth option: the app has no accounts, no sign-in screens and no sign-in service to configure. It owns `src/lib/auth.ts` as a re-export of the core shim, so `@/lib/auth` keeps the same API whichever option you pick - `auth.useSession()` returns a signed-out session and `auth.signOut()` resolves. It also owns `src/hooks/use-auth-redirect.ts` as a no-op `useAuthRedirect()`, so the core root layout is identical for every auth option (the hook documents the contract a real module has to implement). Pick this while you're building screens that don't need a user yet.
## Setup [#setup]
```bash
bun run setup --auth none
```
There's nothing to configure and no keys to paste. The Settings "Account" card only renders for an authenticated session, so it stays hidden.
When you do want accounts, switch with `bun run setup --auth supabase`, `--auth clerk` or `--auth better-auth`.
## Usage [#usage]
The calls exist and are safe to write today, so screens don't need rewriting when you switch:
```ts
import { auth } from "@/lib/auth";
const session = auth.useSession(); // always { status: "unauthenticated" }
await auth.signOut(); // resolves, does nothing
```
`auth.deleteAccount()` also resolves without doing anything, so the Settings "Delete account" row (which only renders for a signed-in user) compiles against this option.
`auth.getAuthHeaders()` resolves to `{}`, and the server half, `src/server/session.ts`, exports a `serverAuth` whose `getRequestUser(request)` always returns `null` (`provider: "none"`). API routes that need a caller therefore answer 401 - payments/stripe does unless you set `STRIPE_ALLOW_ANONYMOUS=true`.
## Gotchas [#gotchas]
* Anything gated on `session.status === "authenticated"` never renders while this option is selected. That includes the Settings Account card.
* Nothing here needs a dev build; Expo Go is fine.
## Reference [#reference]
Everything below is generated from `modules/auth/none/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --auth none
Module id: `auth/none`.
### Files [#files]
3 files copied to the project root
* `src/lib/auth.ts`
* `src/hooks/use-auth-redirect.ts`
* `src/server/session.ts`
# Supabase Auth (/docs/features/auth/supabase)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/auth/supabase - do not edit. */}
Pro
Runs in Expo Go; no dev build needed for this module.
This gives you email/password, Sign in with Apple and Google sign-in on top of `@supabase/supabase-js`, plus the screens to use them: `src/app/(auth)/{sign-in,sign-up,reset,new-password}` with `src/screens/auth/*`, a redirect hook in `src/hooks/use-auth-redirect.ts` that sends signed-out users to `/(auth)/sign-in` and signed-in users out of `(auth)` (onboarding still wins first), and, with `--with-examples`, an example at `src/app/examples/auth.tsx`. `@/lib/auth` keeps the same API whichever option you pick, so `auth.useSession()` and `auth.signOut()` read the same here as under Clerk or Better Auth - what you also get from Supabase is a Postgres database and row-level security behind the same account. The session is persisted through the storage adapter (`@/lib/storage`), so it follows whichever storage module you selected.
## Setup [#setup]
```bash
bun run setup --auth supabase
```
1. Create a project at [supabase.com](https://supabase.com), then open \[Project Settings] → \[API]. Copy the Project URL into `.env` as `EXPO_PUBLIC_SUPABASE_URL` and the anon / publishable key as `EXPO_PUBLIC_SUPABASE_ANON_KEY`. Until both are set auth is disabled: no redirect, `useSession()` stays `unauthenticated`, and the sign-in screen shows "Configure Supabase".
2. In Supabase, open \[Authentication] → \[URL Configuration] → \[Redirect URLs] and add `readynative://` (the scheme from your `readynative.config.ts`) and `readynative://new-password`. Without this the Google and password-reset round trips never come back to the app. In Expo Go the links start with `exp://:8081/--/` instead, so add `exp://**` for your dev project only.
3. Open \[Authentication] → \[Providers] → \[Email] and leave "Confirm email" on (the default). Sign-up then returns `needs_verification` and the user signs in after clicking the mail.
4. Open \[Authentication] → \[Providers] → \[Apple] and enable it. Set Client IDs to your iOS bundle id - the native flow uses `signInWithIdToken`, so you don't need a Services ID.
5. Turn on the Sign in with Apple capability for that bundle id in the \[Apple Developer] portal → \[Certificates, Identifiers & Profiles] → \[Identifiers] → your app id. The `expo-apple-authentication` config plugin handles the rest.
6. Open \[Authentication] → \[Providers] → \[Google] and enable it with a **Web** OAuth client created in \[Google Cloud Console] → \[APIs & Services] → \[Credentials]. Paste Supabase's callback URL into that client's authorized redirect URIs. The app itself never needs a Google client id.
7. Screens call `useT()` with English keys. If you selected an i18n module and haven't added `ru` entries, they render in English - keys fall back to themselves.
Point the app at your production Supabase project: the URL and anon key change, and every redirect URL from step 2 has to exist in that project too. The Google web client needs the production Supabase callback URL added as well.
8. Run `bun run doctor` - every row for this module should be green.
The keys, in one place:
| Key | Where |
| ------------------------------- | ----------------------------------------------------------- |
| `EXPO_PUBLIC_SUPABASE_URL` | Dashboard → Project Settings → API → Project URL |
| `EXPO_PUBLIC_SUPABASE_ANON_KEY` | Dashboard → Project Settings → API → anon / publishable key |
Deps: `@supabase/supabase-js ^2.116`, `expo-apple-authentication`, `expo-auth-session`, `expo-crypto` (`expo-web-browser` is a core dep). Config plugin: `expo-apple-authentication`.
## Usage [#usage]
Read the session anywhere:
```ts
import { auth } from "@/lib/auth";
const session = auth.useSession();
if (session.status === "authenticated") console.log(session.user.email);
```
Sign in with email or with Apple:
```ts
import { signInWithEmail, signInWithApple, canSignInWithApple } from "@/lib/auth";
await signInWithEmail(email, password);
if (canSignInWithApple) await signInWithApple();
```
Sign out, and reach the raw client when you need Postgres:
```ts
import { auth, supabase } from "@/lib/auth";
await auth.signOut();
const { data } = await supabase!.from("profiles").select("*");
```
`auth.openSignIn()` opens `/(auth)/sign-in` from anywhere; Settings → Developer tools uses it. A signed-in user is sent straight back, so sign out first.
### Server routes [#server-routes]
API routes never trust a user id from the client. The app attaches credentials with `auth.getAuthHeaders()` (`authorization: Bearer `, refreshed first if expired; `{}` while signed out), and the route resolves the caller with `serverAuth.getRequestUser(request)` from `src/server/session.ts` (`supabase.auth.getUser(jwt)`: signature, expiry, and that the user still exists; it reads the same `EXPO_PUBLIC_SUPABASE_*` keys). It returns `null` for a missing or invalid credential.
```ts
import { auth } from "@/lib/auth";
const res = await fetch(url, { headers: await auth.getAuthHeaders() });
```
```ts
import { serverAuth } from "@/server/session";
const user = await serverAuth.getRequestUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
```
### Delete account [#delete-account]
Settings ships a confirm-guarded "Delete account" row (App Store Review 5.1.1(v), Play "Account deletion") that calls `auth.deleteAccount()`. The anon client cannot delete auth users, so the module ships `supabase/migrations/20260921000000_delete_user.sql`: a `security definer` function `public.delete_user()` that deletes `auth.users` for `auth.uid()`, granted to `authenticated` only. Supabase forbids `delete from storage.objects` in SQL (a trigger added in January 2026; the Storage API is the documented way), so a second migration, `supabase/migrations/20260924000000_delete_user_storage.sql`, adds `public.list_user_objects()` (`security definer`, returns the caller's objects by `owner_id`), `public.account_deletion_ready()` and two RLS policies on `storage.objects`, "readynative: owners read their own objects" (select) and "readynative: owners delete their own objects" (delete) - `remove()` needs both. Both are scoped to the signed-in user's own files (`owner_id = auth.uid()`) in every bucket; nobody can read or delete anyone else's objects through them. To narrow them to some buckets, add `and bucket_id in ('avatars', 'uploads')` to both `using` clauses in the migration - files in other buckets then survive account deletion, so delete those from your server. Apply both (`supabase db push` or the SQL editor) - `doctor --store` notes when the file is missing. `deleteAccount()` lists the user's files, removes them bucket by bucket with `supabase.storage.from(bucket).remove(paths)` in batches of 1000, re-lists and throws if anything is left (the account is kept), then calls the `delete_user` RPC and `signOut({ scope: "local" })`; `useAuthRedirect` sends the user to sign-in. Before any of that - and before Settings erases the user's analytics and purchase data on your server - `auth.prepareDeleteAccount()` calls `public.account_deletion_ready()` (shipped with the storage migration; `true` once `delete_user()` exists) and `list_user_objects()`, so a missing migration or a broken storage policy stops the deletion while nothing has been touched. If a migration isn't applied (PostgREST `PGRST202`, or `account_deletion_ready()` returning `false`) the error names the migration file to apply. Your own tables clean up with `references auth.users (id) on delete cascade`; anything else, delete inside the function before the `auth.users` row.
## Gotchas [#gotchas]
* The Google flow uses the implicit grant (tokens in the callback URL) as in Supabase's Expo guide; PKCE `code` callbacks are handled too.
* Password reset is a round trip: "Forgot password?" (`/(auth)/reset`) calls `resetPassword(email)`, whose link opens `/(auth)/new-password` (`passwordResetRedirectUri()`). That screen reads the link with `Linking.useLinkingURL()`, activates the recovery session with `startPasswordRecovery(url)` (implicit-flow tokens in the fragment, or a PKCE `code`), and saves the new password with `updatePassword()` → `supabase.auth.updateUser({ password })`. An expired or already-used link shows "This link is invalid or has expired" with a button to request a new one. The recovery session is a real sign-in: a user who opens the link and leaves stays signed in on that device. `use-auth-redirect.ts` skips `new-password` so the screen isn't bounced to Home the moment the session starts. With the default implicit flow the link works on any device that has the app; if you switch the client to `flowType: "pkce"`, it only works on the device that asked for it.
* Expo Go works for everything here: Sign in with Apple runs in Expo Go on iOS, and Google opens the system browser.
Check it works (the Examples steps need a tree set up with `--with-examples`):
1. Put both keys in `.env`, run `npx expo start`, and open a fresh install: onboarding → `/(auth)/sign-in`.
2. Sign up with a new email → toast "Check your email" → confirm the mail → sign in → you land on `/` and Settings shows the Account card.
3. Settings → Sign out → back on sign-in.
4. Sign in with a wrong password → error toast with Supabase's message, no navigation.
5. On iOS, tap Continue with Apple → native sheet → signed in; the name appears in Settings on the first sign-in.
6. Tap Continue with Google → system browser → back in the app signed in (this needs the redirect URL from setup step 2).
7. Tap Forgot password → the mail arrives → the link opens the app at `readynative://new-password` → enter a new password twice → "Password updated" and you land on Home. Sign out, sign in with the new password, sign out again and open the same link → "This link is invalid or has expired".
8. Kill and reopen the app → still signed in, session restored from storage.
9. Examples → Auth session → the JSON shows `status: "authenticated"`.
## Remove it [#remove-it]
While `modules/` exists (a tree set up with `--keep-modules`), `setup` does all of it:
bun run setup --auth none --yes --keep-modules
In a finalized tree `setup` is a stub, so you undo it by hand. Here is everything this module added:
1. **Delete the files** that are still there (the demo screens are gone already unless you set up with `--with-examples`): `src/app/(auth)/_layout.tsx`, `src/app/(auth)/new-password.tsx`, `src/app/(auth)/reset.tsx`, `src/app/(auth)/sign-in.tsx`, `src/app/(auth)/sign-up.tsx`, `src/app/examples/auth.tsx`, `src/lib/__tests__/auth-supabase.test.tsx`, `src/screens/auth/auth-form.ts`, `src/screens/auth/new-password-screen.tsx`, `src/screens/auth/reset-screen.tsx`, `src/screens/auth/sign-in-screen.tsx`, `src/screens/auth/sign-up-screen.tsx`, `src/screens/examples/auth-example-screen.tsx`, `src/server/__tests__/session-supabase.test.ts`, `supabase/migrations/20260921000000_delete_user.sql`, `supabase/migrations/20260924000000_delete_user_storage.sql`.
2. **Replace, don't delete** `src/hooks/use-auth-redirect.ts`, `src/lib/auth.ts`, `src/server/session.ts`: core code imports them, so swap in the no-op version from `modules/auth/none/files/` of a fresh clone of your tier repo - same exports, nothing behind them.
3. **Uninstall the dependencies**: `bun remove @supabase/supabase-js expo-apple-authentication expo-auth-session expo-crypto`.
4. **Drop the config plugin** `expo-apple-authentication` from `.readynative.json` → `modules.app.expo.plugins` (that is where `app.config.ts` reads it from), then rebuild the dev build.
5. **Unwrap the provider**: delete `` and its import from `src/providers.tsx`.
6. **Remove the env keys** `EXPO_PUBLIC_SUPABASE_URL`, `EXPO_PUBLIC_SUPABASE_ANON_KEY` from `.env`, `.env.example` and your EAS environment, and `EXPO_PUBLIC_SUPABASE_URL`, `EXPO_PUBLIC_SUPABASE_ANON_KEY` from `src/lib/env.ts`.
7. **Update the privacy declarations**: remove this module's entries from `.readynative.json` → `modules.app.expo.ios.privacyManifests`, then re-run `bun run gen:privacy` and revise your store privacy answers.
8. **Check it**: `bun run typecheck` and `bun run lint` point at anything that still imports the removed files; `bun run gen:graph` refreshes `docs/ARCHITECTURE.md`.
## Reference [#reference]
Everything below is generated from `modules/auth/supabase/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --auth supabase
Module id: `auth/supabase` (the default for this category).
### Dependencies [#dependencies]
| Package | Version | Kind |
| --------------------------- | ---------- | --------------------------- |
| `@supabase/supabase-js` | `^2.116.0` | dependency |
| `expo-apple-authentication` | `~57.0.2` | dependency (`expo install`) |
| `expo-auth-session` | `~57.0.12` | dependency (`expo install`) |
| `expo-crypto` | `~57.0.3` | dependency (`expo install`) |
### Config plugins [#config-plugins]
Merged into `app.config.ts` through `.readynative.json` (`modules.app`):
* `expo-apple-authentication`
### Environment keys [#environment-keys]
| Key | Required | Server-only | Example | Docs |
| ------------------------------- | -------- | ----------- | -------------------------------- | ------------------------------------------------------------------ |
| `EXPO_PUBLIC_SUPABASE_URL` | yes | no | `https://xyzcompany.supabase.co` | [dashboard](https://supabase.com/dashboard/project/_/settings/api) |
| `EXPO_PUBLIC_SUPABASE_ANON_KEY` | yes | no | `sb_publishable_...` | [dashboard](https://supabase.com/dashboard/project/_/settings/api) |
Keys go in `.env` (see `.env.example`). `Required` keys are checked by `bun run doctor`; `Server-only` keys have no `EXPO_PUBLIC_` prefix, are read only by API routes and never reach the bundle.
### Privacy [#privacy]
Play Data safety draft: collects *Email address, name (account)*, *Auth tokens*, *IP address (auth logs)*; shared with Supabase (processor). Source of truth: [vendor disclosure](https://supabase.com/privacy).
Apple privacy manifest data types (composed into `ios.privacyManifests` by setup):
| Type | Linked to user | Tracking | Purposes |
| -------------- | -------------- | -------- | ---------------- |
| `EmailAddress` | yes | no | AppFunctionality |
| `Name` | yes | no | AppFunctionality |
| `UserID` | yes | no | AppFunctionality |
### Providers [#providers]
Rendered in `src/providers.tsx` (lower order = outermost):
| Order | Provider | From |
| ----- | -------------- | ------------ |
| 30 | `AuthProvider` | `@/lib/auth` |
### Compatibility [#compatibility]
* Requires `storage` = [`kv-store`](https://readynative.app/docs/features/storage/kv-store), [`mmkv`](https://readynative.app/docs/features/storage/mmkv), [`async-storage`](https://readynative.app/docs/features/storage/async-storage)
### Doctor checks [#doctor-checks]
* env: `EXPO_PUBLIC_SUPABASE_URL`, `EXPO_PUBLIC_SUPABASE_ANON_KEY`
### After setup [#after-setup]
1. Supabase: create a project, copy Project URL + anon/publishable key from Settings → API into .env (EXPO\_PUBLIC\_SUPABASE\_URL, EXPO\_PUBLIC\_SUPABASE\_ANON\_KEY).
2. Supabase: run supabase/migrations/20260921000000\_delete\_user.sql (SQL editor or `supabase db push`) - the Settings → Delete account row calls rpc('delete\_user').
3. Supabase: Authentication → URL Configuration → add `readynative://` and `readynative://new-password` (your scheme) to Redirect URLs - Google sign-in and the password-reset link return through them.
4. Supabase: Authentication → Providers → Apple: enable, add the iOS bundle id to Client IDs (native Sign in with Apple).
5. Supabase: Authentication → Providers → Google: enable with a Web OAuth client id/secret from Google Cloud Console.
6. Apple: enable the Sign in with Apple capability for the bundle id (EAS does it on the first build).
### Files [#files]
19 files copied to the project root
* `src/app/(auth)/_layout.tsx`
* `src/app/(auth)/new-password.tsx`
* `src/app/(auth)/reset.tsx`
* `src/app/(auth)/sign-in.tsx`
* `src/app/(auth)/sign-up.tsx`
* `src/app/examples/auth.tsx`
* `src/hooks/use-auth-redirect.ts`
* `src/lib/__tests__/auth-supabase.test.tsx`
* `src/lib/auth.ts`
* `src/screens/auth/auth-form.ts`
* `src/screens/auth/new-password-screen.tsx`
* `src/screens/auth/reset-screen.tsx`
* `src/screens/auth/sign-in-screen.tsx`
* `src/screens/auth/sign-up-screen.tsx`
* `src/screens/examples/auth-example-screen.tsx`
* `src/server/__tests__/session-supabase.test.ts`
* `src/server/session.ts`
* `supabase/migrations/20260921000000_delete_user.sql`
* `supabase/migrations/20260924000000_delete_user_storage.sql`
# API routes (/docs/features/backend/api-routes)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/backend/api-routes - do not edit. */}
Pro
Runs in Expo Go; no dev build needed for this module.
This turns on Expo Router's server routes so your app has a backend in the same tree: `src/app/api/health+api.ts` answers `GET /api/health` with `{ ok, version, variant }`, `src/app/api/echo+api.ts` answers `POST /api/echo { message }` with zod validation and a 400 on bad input, and `src/server/` holds the pieces you'll reuse - `json.ts` (`json()`, `error()`, `readJson(request, schema)`, `handle()` which maps BadRequest to 4xx and anything else to 500 without leaking a stack), `env.ts` (`serverEnv()`, zod over `process.env` for keys that must never be `EXPO_PUBLIC_`), and `webhooks/README.md` with the handler pattern. If you set up with `--with-examples` there's an example at `/examples/api`. Pick this when a module needs a server - `auth/better-auth` requires it and `payments/stripe` wants it - or when you'd rather not stand up a separate backend yet.
## Setup [#setup]
```bash
bun run setup --backend api-routes
```
1. The module patches the app config with `web.output: "server"`, which is what makes API routes build. Your native export is unaffected, but the static web export is gone while this module is selected.
2. Run `bun run start`. The dev server serves `src/app/api/**` at `http://localhost:8081/api/*` - check it with `curl -s http://localhost:8081/api/health`, which returns `{"ok":true,...}`.
3. Set `EXPO_PUBLIC_API_URL` in `.env` to `http://:8081` and restart with `bun run start -- -c`. A physical device can't reach `localhost`, and the example screen shows an EmptyState while the key is unset.
4. Add any server-only key to `.env` under the `# server (never EXPO_PUBLIC)` heading. The shipped one is `WEBHOOK_SECRET` (any random string), read through `serverEnv().WEBHOOK_SECRET`.
5. Deploy when you're ready: `bunx eas-cli login && bunx eas-cli init` (it prints the project id - paste it into `readynative.config.ts` → `app.easProjectId`), then `npx expo export -p web` and `eas deploy` - add `--prod` for the production alias. The guide is at [https://docs.expo.dev/eas/hosting/get-started/](https://docs.expo.dev/eas/hosting/get-started/).
6. Write custom webhooks against the pattern in `src/server/webhooks/README.md`: verify the secret, parse with zod, and return 2xx only once you've actually handled it. The auth (`/api/auth/[...all]`) and payments handlers ship with their own modules.
Set the server keys in EAS rather than `.env`: `eas env:set --environment production --name WEBHOOK_SECRET --value … --visibility sensitive`, and deploy with `eas deploy --environment production` so the routes see them (EAS Hosting can't deploy `secret` variables). Point `EXPO_PUBLIC_API_URL` at `https://.expo.app` per build profile in `eas.json` `env`, and optionally set `expo.extra.router.origin` so relative `fetch("/api/…")` calls resolve ([https://docs.expo.dev/router/reference/api-routes/#deployment](https://docs.expo.dev/router/reference/api-routes/#deployment)). A native release build has no API routes without a deployed server.
7. Run `bun run doctor` - every row for this module should be green.
The keys, in one place:
| Key | Where | Get it |
| ------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| `EXPO_PUBLIC_API_URL` | client (`env.API_URL`); shared with the data modules | `http://:8081` while `expo start` runs, else the hosted URL |
| `WEBHOOK_SECRET` | server only (`serverEnv().WEBHOOK_SECRET`), `.env`/EAS | any random string; also `eas env:set --environment production --visibility sensitive` |
| `POSTHOG_PERSONAL_API_KEY` | server only, optional | PostHog → Settings → Personal API keys, scope `person:write`; used by `/api/privacy/delete` |
| `POSTHOG_PROJECT_ID` | server only, optional | PostHog → Project settings → Project ID |
| `POSTHOG_API_HOST` | server only, optional | default `https://us.posthog.com`; `https://eu.posthog.com` for EU projects |
| `AMPLITUDE_API_KEY`, `AMPLITUDE_SECRET_KEY` | server only, optional | Amplitude → Settings → Projects → your project → API key and Secret key |
| `AMPLITUDE_REGION` | server only, optional | `eu` for EU-hosted projects; US otherwise |
| `REVENUECAT_SECRET_KEY` | server only, optional | RevenueCat → Project settings → API keys → secret key |
| `CLERK_WEBHOOK_SIGNING_SECRET` | server only, optional | Clerk → Webhooks → your `/api/webhooks/clerk` endpoint → Signing secret (`whsec_…`) |
Server keys are listed in `.env.example` under `# server (never EXPO_PUBLIC)`; `bun run doctor` checks the ones a module marks `required`. Expo Go works, since the routes run on the dev server or EAS Hosting, not on the device.
## Usage [#usage]
Write a route as a `+api.ts` file, using the shared helpers:
```ts
// src/app/api/greet+api.ts
import { z } from "zod";
import { handle, json, readJson } from "@/server/json";
const Body = z.object({ name: z.string().min(1) });
export const POST = handle(async (request) => {
const { name } = await readJson(request, Body);
return json({ greeting: `Hi ${name}` });
});
```
Read a server-only key - never `process.env` directly, and never from client code:
```ts
import { serverEnv } from "@/server/env";
const secret = serverEnv().WEBHOOK_SECRET;
```
Call it from the app against the configured origin:
```ts
import { env } from "@/lib/env";
const res = await fetch(`${env.API_URL}/api/health`);
```
### Account deletion [#account-deletion]
`POST /api/privacy/delete` authenticates the caller with `serverAuth.getRequestUser` (401 signed out, 503 when server auth isn't configured) and erases the verified user id in every service whose server keys are set:
* PostHog: `POST {POSTHOG_API_HOST}/api/projects/{POSTHOG_PROJECT_ID}/persons/bulk_delete/` with `POSTHOG_PERSONAL_API_KEY`, `distinct_ids`, `delete_events: true` and `delete_recordings: true`.
* Amplitude: the User Privacy API v1, `POST https://amplitude.com/api/2/deletions/users` (`https://analytics.eu.amplitude.com` with `AMPLITUDE_REGION=eu`), Basic auth `AMPLITUDE_API_KEY:AMPLITUDE_SECRET_KEY`, `user_ids` and `ignore_invalid_id: true`.
* RevenueCat: `DELETE https://api.revenuecat.com/v1/subscribers/{app_user_id}` with `REVENUECAT_SECRET_KEY`; a 404 counts as deleted.
It answers 200 `{ results }` when every service succeeded and 502 when one failed - the app then keeps the account so the user can retry. Settings calls `deleteServerData()` from `@/lib/account-data` (a POST to `${EXPO_PUBLIC_API_URL}/api/privacy/delete` with `auth.getAuthHeaders()`) right before `auth.deleteAccount()`; with `backend=none` it's a no-op. PostHog and Amplitude can only find the user if the app called `analytics.identify(session.user.id)`; RevenueCat's app user id is already the auth user id.
With Clerk, `POST /api/webhooks/clerk` handles `user.deleted` the same way. It verifies the Svix / Standard Webhooks signature with WebCrypto (no SDK, `src/server/standard-webhooks.ts`) against `CLERK_WEBHOOK_SIGNING_SECRET` with a 5-minute timestamp tolerance, and answers 404 while the secret is unset.
## Gotchas [#gotchas]
* `web.output: "server"` makes `npx expo export -p web` produce a server bundle for EAS Hosting or Node, so the static web export is gone while this module is selected.
* API routes aren't available in a native release build without a deployed server - point `EXPO_PUBLIC_API_URL` at EAS Hosting before you ship.
* `EXPO_PUBLIC_API_URL` is shared with the data and auth modules; when several are selected, the first one's example value wins in `.env.example`.
Check it works (the Examples steps need a tree set up with `--with-examples`):
1. Run `bun run start`, then `curl -s localhost:8081/api/health` → `{"ok":true,"version":"1.0.0","variant":"dev"}`.
2. `curl -s -X POST localhost:8081/api/echo -H 'content-type: application/json' -d '{"message":"hi"}'` echoes it back; sending `{}` gives a 400 with `{ error }`.
3. Set `EXPO_PUBLIC_API_URL` to the LAN URL and open Examples → "API route" on a device: it shows `ok · version · variant`. Unset it and you get the EmptyState.
4. After `eas deploy`, repeat step 1 against `https://.expo.app`.
## Remove it [#remove-it]
Remove [`auth/better-auth`](https://readynative.app/docs/features/auth/better-auth) and [`payments/stripe`](https://readynative.app/docs/features/payments/stripe) first if you use it - it requires this module.
While `modules/` exists (a tree set up with `--keep-modules`), `setup` does all of it:
bun run setup --backend none --yes --keep-modules
In a finalized tree `setup` is a stub, so you undo it by hand. Here is everything this module added:
1. **Delete the files** that are still there (the demo screens are gone already unless you set up with `--with-examples`): `src/app/api/echo+api.ts`, `src/app/api/health+api.ts`, `src/app/api/privacy/delete+api.ts`, `src/app/api/webhooks/clerk+api.ts`, `src/app/examples/api.tsx`, `src/screens/examples/api-example-screen.tsx`, `src/server/__tests__/privacy.test.ts`, `src/server/__tests__/routes.test.ts`, `src/server/env.ts`, `src/server/json.ts`, `src/server/privacy.ts`, `src/server/standard-webhooks.ts`, `src/server/webhooks/README.md`.
2. **Replace, don't delete** `src/lib/account-data.ts`: core code imports it, so swap in the no-op version from `modules/backend/none/files/` of a fresh clone of your tier repo - same exports, nothing behind them.
3. **Remove the env keys** `EXPO_PUBLIC_API_URL`, `WEBHOOK_SECRET`, `POSTHOG_PERSONAL_API_KEY`, `POSTHOG_PROJECT_ID`, `POSTHOG_API_HOST`, `AMPLITUDE_API_KEY`, `AMPLITUDE_SECRET_KEY`, `AMPLITUDE_REGION`, `REVENUECAT_SECRET_KEY`, `CLERK_WEBHOOK_SIGNING_SECRET` from `.env`, `.env.example` and your EAS environment, and `EXPO_PUBLIC_API_URL` from `src/lib/env.ts`.
4. **Check it**: `bun run typecheck` and `bun run lint` point at anything that still imports the removed files; `bun run gen:graph` refreshes `docs/ARCHITECTURE.md`.
## Reference [#reference]
Everything below is generated from `modules/backend/api-routes/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --backend api-routes
Module id: `backend/api-routes` (the default for this category).
### Environment keys [#environment-keys]
| Key | Required | Server-only | Example | Docs |
| ------------------------------ | -------- | ----------- | ------------------------------------------------- | ------------------------------------------------------------------------- |
| `EXPO_PUBLIC_API_URL` | no | no | `http://localhost:8081` | [dashboard](https://docs.expo.dev/router/reference/api-routes/) |
| `WEBHOOK_SECRET` | no | yes | `change-me` | [dashboard](https://docs.expo.dev/router/reference/api-routes/#security) |
| `POSTHOG_PERSONAL_API_KEY` | no | yes | `phx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | [dashboard](https://posthog.com/docs/api#private-endpoint-authentication) |
| `POSTHOG_PROJECT_ID` | no | yes | `12345` | [dashboard](https://app.posthog.com/settings/project) |
| `POSTHOG_API_HOST` | no | yes | `https://us.posthog.com` | [dashboard](https://posthog.com/docs/api) |
| `AMPLITUDE_API_KEY` | no | yes | - | [dashboard](https://amplitude.com/docs/apis/analytics/user-privacy) |
| `AMPLITUDE_SECRET_KEY` | no | yes | - | [dashboard](https://amplitude.com/docs/apis/analytics/user-privacy) |
| `AMPLITUDE_REGION` | no | yes | `us` | [dashboard](https://amplitude.com/docs/apis/analytics/user-privacy) |
| `REVENUECAT_SECRET_KEY` | no | yes | `sk_xxxxxxxxxxxxxxxxxxxxxxxx` | [dashboard](https://www.revenuecat.com/docs/api-v1/customers) |
| `CLERK_WEBHOOK_SIGNING_SECRET` | no | yes | `whsec_xxxxxxxxxxxxxxxxxxxxxxxx` | [dashboard](https://clerk.com/docs/webhooks/sync-data) |
Keys go in `.env` (see `.env.example`). `Required` keys are checked by `bun run doctor`; `Server-only` keys have no `EXPO_PUBLIC_` prefix, are read only by API routes and never reach the bundle.
### Doctor checks [#doctor-checks]
* env: `EXPO_PUBLIC_API_URL`
### After setup [#after-setup]
1. API routes: `expo start` serves src/app/api/\*\* on the dev server; set EXPO\_PUBLIC\_API\_URL=http\://\:8081 in .env for a device
2. Deploy: `npx eas-cli login`, `eas init`, then `npx expo export -p web && eas deploy` (EAS Hosting) and point EXPO\_PUBLIC\_API\_URL at the hosted URL
3. Server secrets (WEBHOOK\_SECRET, …) live in .env locally and in `eas env:set --environment production --visibility sensitive` for EAS Hosting - never EXPO\_PUBLIC\_
4. Account deletion: Settings → Delete account first calls POST /api/privacy/delete, which erases the user in PostHog / Amplitude / RevenueCat when their server keys are set (POSTHOG\_PERSONAL\_API\_KEY + POSTHOG\_PROJECT\_ID, AMPLITUDE\_API\_KEY + AMPLITUDE\_SECRET\_KEY, REVENUECAT\_SECRET\_KEY). With Clerk, point a `user.deleted` webhook at /api/webhooks/clerk and set CLERK\_WEBHOOK\_SIGNING\_SECRET.
### Files [#files]
14 files copied to the project root
* `src/app/api/echo+api.ts`
* `src/app/api/health+api.ts`
* `src/app/api/privacy/delete+api.ts`
* `src/app/api/webhooks/clerk+api.ts`
* `src/app/examples/api.tsx`
* `src/lib/account-data.ts`
* `src/screens/examples/api-example-screen.tsx`
* `src/server/__tests__/privacy.test.ts`
* `src/server/__tests__/routes.test.ts`
* `src/server/env.ts`
* `src/server/json.ts`
* `src/server/privacy.ts`
* `src/server/standard-webhooks.ts`
* `src/server/webhooks/README.md`
# Backend (/docs/features/backend)
{/* Generated by apps/docs/scripts/gen-modules.ts; the intro is modules/backend/README.md - do not edit. */}
Pro
Hey - here's how the backend category works in ReadyNative.
This one is about whether your app ships a server of its own. With Expo Router, a `+api.ts` file under `src/app/api/` is a route handler that runs on the dev server in development and on EAS Hosting in production - same repo, same TypeScript, one deploy.
Unlike the other categories there's no `@/lib/backend` shim, because there's no client API to keep stable. What stays constant is `EXPO_PUBLIC_API_URL`: the origin your app calls, whether that's routes in this tree or a backend you run elsewhere. `backend/none` is the no-op - it just means no routes here.
Pick **API routes** when a module needs a server (`auth/better-auth` requires it, `payments/stripe` wants it for Checkout and webhooks) or when you'd rather write one endpoint than stand up a separate service. Pick **none** when your backend already exists somewhere else, or when the app doesn't need one - it also keeps the static web export, which API routes replace with a server bundle.
This category is part of ReadyNative Pro.
## Options [#options]
One option per category - `--backend ` picks it:
bun run setup --backend api-routes
| Option | Label | Expo Go | Hint |
| ---------------------------- | -------------------- | ------- | -------------------------------------------------------------------------- |
| [`api-routes`](./api-routes) | API routes (default) | yes | Expo Router +api.ts routes · src/server helpers · EAS Hosting · Expo Go OK |
| [`none`](./none) | No API routes | yes | no src/app/api |
# No API routes (/docs/features/backend/none)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/backend/none - do not edit. */}
Pro
Runs in Expo Go; no dev build needed for this module.
This is the no-backend option: no `src/app/api/*+api.ts` routes, no `src/server/` helpers, and `web.output` stays as it is, so `npx expo export -p web` still produces a static site. Pick this when the app talks to a backend you already run somewhere else, or when it doesn't need a server at all.
## Setup [#setup]
```bash
bun run setup --backend none
```
There's nothing to configure. If you're calling an API you host elsewhere, set `EXPO_PUBLIC_API_URL` in `.env` to its origin and use it from `@/lib/env` - the data modules read the same key.
When you want routes in this tree, switch with `bun run setup --backend api-routes`.
## Usage [#usage]
Fetch from wherever your backend lives:
```ts
import { env } from "@/lib/env";
const res = await fetch(`${env.API_URL}/health`);
```
## Gotchas [#gotchas]
* `auth/better-auth` requires `backend/api-routes`, so `bun run setup` won't let you pair it with this option. `payments/stripe` only wants it: its routes are then never served and the paywall shows "Configure Stripe".
* Nothing here needs a dev build; Expo Go is fine.
## Reference [#reference]
Everything below is generated from `modules/backend/none/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --backend none
Module id: `backend/none`.
### Files [#files]
1 files copied to the project root
* `src/lib/account-data.ts`
# Consent sheet (GDPR / CCPA) (/docs/features/consent/consent)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/consent/consent - do not edit. */}
Runs in Expo Go; no dev build needed for this module.
This asks before anything is sent: `src/lib/consent.ts` implements the `@/lib/consent` contract on top of the core consent store, and `ConsentProvider` (order 85, in `src/components/consent-sheet.tsx`) mounts the sheet above the app. The `analytics/*` and `crash/*` modules follow the consent store from import time on and only create their SDK clients once the matching category is consented to, so PostHog, Amplitude and Sentry send nothing - no events, no flags request, no crash report - until the user says yes where the law wants that. Setup picks this option automatically whenever an analytics or crash module is selected (and `none` when neither is); pass `--consent` to override.
## Setup [#setup]
```bash
bun run setup --consent consent
```
Setup installs `expo-localization` (for the device region), registers `ConsentProvider`, and adds the sheet and its tests. The only knob is in `readynative.config.ts`:
```ts
privacy: { askEverywhere: false }, // true: ask every user, whatever the region
```
## How it decides [#how-it-decides]
| Device | Undecided reads as | Sheet |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | -------------------------------------- |
| Any locale region (region setting or language region) in the EU/EEA, UK, Switzerland, Canada or Brazil, a `Europe/*` time zone, or no region at all | `false` | shown once, after onboarding |
| Anywhere else (California included) | `true` | never; Settings → Privacy has switches |
| `privacy.askEverywhere: true` | `false` | shown to everyone |
`CA` is Canada - a device cannot tell US states apart, so California users get the CCPA opt-out ("Do not sell or share", below) rather than a sheet.
`consent.needsPrompt()` is true when the stored record is not for the current `CONSENT_VERSION` (`src/stores/consent.ts`) and the device is in the ask group. Once the sheet is dismissed (any way - "Only necessary" is the default for Android back / swipe down) the record is written and it does not show again. **Raise `CONSENT_VERSION` when the sheet asks for something new**: every user in the ask group is asked again, and until they answer their old choices read as undecided.
The record persists under `readynative:consent` and survives sign-out (`wipeLocalData()` keeps it):
```ts
interface ConsentState {
analytics: boolean | null; // null = undecided
crash: boolean | null;
doNotSell: boolean;
version: number | null; // CONSENT_VERSION the answer was given to
decidedAt: string | null; // ISO dates
updatedAt: string | null;
source: "sheet" | "settings" | "do-not-sell" | "legacy" | null;
}
```
The sheet and the Settings switches only show the categories this build has (`privacyCategories` in `src/lib/privacy-categories.ts`: `analytics` when an analytics module is selected, `crash` when a crash module is). With neither, the sheet never opens.
## Usage [#usage]
```ts
import { consent } from "@/lib/consent";
if (consent.get().analytics) analytics.track("paywall_seen");
const { crash } = consent.useConsent(); // reactive, inside a component
consent.set({ analytics: false }); // what the Settings switches do
consent.setDoNotSell(true); // the CCPA switch: analytics reads as off while it is on
```
`consent.reprompt()` shows the sheet now, whatever the region and the stored answer, until it's answered; Settings → Developer tools uses it.
Adding a category (say `marketing`): add it to `ConsentCategory` and `ConsentState` in `src/stores/consent.ts`, a row in `consent-sheet.tsx` and Settings, include it in `privacyCategories`, and read it where you need it.
## Gotchas [#gotchas]
* The sheet waits 400 ms after the first screen so `useOnboardingRedirect` can win; on the `onboarding` route it never opens. `ConsentProvider` loads the onboarding store itself, so with `onboarding/off` the sheet opens on the first screen.
* Region comes from the device (locales and time zone), not IP. A French user with a US region and a US time zone sees no sheet; the Settings switches are always there. Set `privacy.askEverywhere` if that trade-off is not good enough for you.
* With AsyncStorage the consent store loads asynchronously, so events fired before it has loaded (the first screen view) are dropped rather than sent without a known answer. kv-store and MMKV load it synchronously.
* This is a consent *mechanism*, not legal advice: your privacy policy still has to say what you collect (see `bun run gen:privacy`).
## Remove it [#remove-it]
While `modules/` exists (a tree set up with `--keep-modules`), `setup` does all of it:
bun run setup --consent none --yes --keep-modules
In a finalized tree `setup` is a stub, so you undo it by hand. Here is everything this module added:
1. **Delete the files** that are still there: `src/components/__tests__/consent-sheet.test.tsx`, `src/components/consent-sheet.tsx`, `src/lib/__tests__/consent.test.ts`.
2. **Replace, don't delete** `src/lib/consent.ts`: core code imports it, so swap in the no-op version from `modules/consent/none/files/` of a fresh clone of your tier repo - same exports, nothing behind them.
3. **Uninstall the dependencies**: `bun remove expo-localization`. Keep any of `expo-localization` (also used by `i18n/i18next`, `i18n/lingui`, `analytics/posthog`) that another module you picked still needs.
4. **Drop the config plugin** `expo-localization` from `.readynative.json` → `modules.app.expo.plugins` (that is where `app.config.ts` reads it from), then rebuild the dev build.
5. **Unwrap the provider**: delete `` and its import from `src/providers.tsx`.
6. **Check it**: `bun run typecheck` and `bun run lint` point at anything that still imports the removed files; `bun run gen:graph` refreshes `docs/ARCHITECTURE.md`.
## Reference [#reference]
Everything below is generated from `modules/consent/consent/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --consent consent
Module id: `consent/consent` (the default for this category).
### Dependencies [#dependencies]
| Package | Version | Kind |
| ------------------- | --------- | --------------------------- |
| `expo-localization` | `~57.0.2` | dependency (`expo install`) |
### Config plugins [#config-plugins]
Merged into `app.config.ts` through `.readynative.json` (`modules.app`):
* `expo-localization`
### Providers [#providers]
Rendered in `src/providers.tsx` (lower order = outermost):
| Order | Provider | From |
| ----- | ----------------- | ---------------------------- |
| 85 | `ConsentProvider` | `@/components/consent-sheet` |
### After setup [#after-setup]
1. Consent: test it - set the simulator region to Germany (Settings → General → Language & Region) and relaunch: the sheet appears after onboarding. Set region and time zone to the US: no sheet, and the Settings → Privacy toggles are on. `privacy.askEverywhere: true` in readynative.config.ts asks everyone.
2. Consent: the sheet text is yours to edit in src/components/consent-sheet.tsx; keep 'Only necessary' as easy to reach as 'Accept all' (that is what regulators check).
### Files [#files]
4 files copied to the project root
* `src/components/__tests__/consent-sheet.test.tsx`
* `src/components/consent-sheet.tsx`
* `src/lib/__tests__/consent.test.ts`
* `src/lib/consent.ts`
# Privacy consent (/docs/features/consent)
{/* Generated by apps/docs/scripts/gen-modules.ts; the intro is modules/consent/README.md - do not edit. */}
Hey - here's how privacy consent works in ReadyNative. This category is a yes/no: does a consent
sheet ask before analytics and crash reporting start?
What stays the same either way: the consent store (`src/stores/consent.ts`, key
`readynative:consent`), the `@/lib/consent` contract (`consent.get() / useConsent() / set() /
setDoNotSell()`), and the Settings Privacy card ("Export my data", and "Do not sell or share"
whenever an analytics module is selected) are core. The analytics and crash modules follow the
consent store and only create their SDK clients once their category is consented to, so the SDK
files are identical under both options. Setup picks the option for you - `consent` when an
analytics or crash module is selected, `none` otherwise - and `bun run setup --consent `
overrides it.
* **consent** - a sheet after onboarding wherever a device locale region or the time zone points
to the EU/EEA, UK, Switzerland, Canada or Brazil (and wherever the device has no region; or
everywhere with `privacy.askEverywhere`): one switch per category the build has, "Accept all" or
"Only necessary". SDKs stay off there until answered; elsewhere they start on and the Settings
switches turn them off. Needs `expo-localization` (installed by the module).
* **none** - nothing asks; undecided reads as consented, "Do not sell or share" still turns
analytics off. Right when you ship no analytics or crash module (the default preset).
## Options [#options]
One option per category - `--consent ` picks it:
bun run setup --consent consent
| Option | Label | Expo Go | Hint |
| ---------------------- | ------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
| [`consent`](./consent) | Consent sheet (GDPR / CCPA) (default) | yes | asks before analytics + crash start (EU/EEA, UK, Switzerland, Canada, Brazil or everywhere); Settings toggles · Expo Go OK |
| [`none`](./none) | No consent sheet | yes | consent.get() reads all-true; analytics and crash SDKs start as before |
# No consent sheet (/docs/features/consent/none)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/consent/none - do not edit. */}
Runs in Expo Go; no dev build needed for this module.
Nothing asks the user anything: `src/lib/consent.ts` is a re-export of the core shim, so an undecided category reads as consented and the analytics / crash SDKs start on the first launch. What the user did say still counts: the Settings "Do not sell or share" switch (shown when an analytics module is selected) turns analytics off, and an explicit `false` in the consent store (`src/stores/consent.ts`) is honoured - a later `bun run setup --consent consent` keeps both. Setup picks this option when no analytics or crash module is selected; pick it by hand (`--consent none`) only when your app never ships to a region with consent rules.
## Setup [#setup]
```bash
bun run setup --consent none
```
That's it - there's nothing to configure.
## Usage [#usage]
```ts
import { consent } from "@/lib/consent";
consent.get(); // { analytics: true, crash: true } until "Do not sell or share" is on
consent.setDoNotSell(true); // → { analytics: false, crash: true }
consent.needsPrompt(); // always false
```
## Gotchas [#gotchas]
* With `analytics/*` or `crash/sentry` selected, this option means events flow from the first launch and there are no per-category switches in Settings (only "Do not sell or share", which covers analytics). In the EU/UK that needs a consent mechanism of your own; `consent/consent` ships one, and setup selects it for you unless you pass `--consent none`.
## Reference [#reference]
Everything below is generated from `modules/consent/none/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --consent none
Module id: `consent/none`.
### Files [#files]
1 files copied to the project root
* `src/lib/consent.ts`
# Crash reporting (/docs/features/crash)
{/* Generated by apps/docs/scripts/gen-modules.ts; the intro is modules/crash/README.md - do not edit. */}
Pro
Hey - here's how crash reporting works in ReadyNative.
This category owns the errors you didn't see coming: the one call you make for handled errors, and the app-wide boundary that keeps a render error from turning into a white screen.
`@/lib/crash` always exists, whichever option you pick: `crash.capture(err, ctx)` reads the same in both, and `crash/none` is a no-op that only logs in dev. Calls you write against the shim keep working when you switch options, and a missing DSN never crashes the app - it just means nothing is sent.
Pick **Sentry** for the reports: JS and native crashes, breadcrumbs, source-mapped stack traces, and an error boundary whose fallback is the app's own ErrorState with Retry. Pick **none** while you're building alone and nobody else can hit a bug you can't reproduce - it's one command to switch later, and the calls you already wrote keep compiling.
This category is part of ReadyNative Pro.
## Options [#options]
One option per category - `--crash ` picks it:
bun run setup --crash sentry
| Option | Label | Expo Go | Hint |
| -------------------- | ------------------ | ------- | --------------------------------------------------------- |
| [`sentry`](./sentry) | Sentry (default) | yes | JS + native crash reporting · Expo Go OK (JS errors only) |
| [`none`](./none) | No crash reporting | yes | crash.capture is a no-op |
# No crash reporting (/docs/features/crash/none)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/crash/none - do not edit. */}
Pro
Runs in Expo Go; no dev build needed for this module.
This is the no-op crash option: no SDK, no DSN, nothing leaving the device. It owns `src/lib/crash.ts` as a re-export of the core shim, so `@/lib/crash` keeps the same API whichever option you pick - `crash.capture(err, ctx)` does nothing beyond a `console.error` in dev. Pick this while you're building alone; pick Sentry before real users can hit a bug you can't reproduce.
## Setup [#setup]
```bash
bun run setup --crash none
```
There's nothing to configure and no keys to paste.
When you want the reports, switch with `bun run setup --crash sentry`.
## Usage [#usage]
The call exists and is safe to write today, so screens don't need rewriting when you switch:
```ts
import { crash } from "@/lib/crash";
crash.capture(err, { screen: "checkout", orderId }); // logs in dev, no-op otherwise
```
## Gotchas [#gotchas]
* The shim has no `CrashBoundary`, so don't import one - that component comes with `crash/sentry`. An unhandled render error takes down the tree it's in.
* Nothing here needs a dev build; Expo Go is fine.
## Reference [#reference]
Everything below is generated from `modules/crash/none/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --crash none
Module id: `crash/none`.
### Files [#files]
1 files copied to the project root
* `src/lib/crash.ts`
# Sentry (/docs/features/crash/sentry)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/crash/sentry - do not edit. */}
Pro
Runs in Expo Go; no dev build needed for this module.
This reports crashes and handled errors to Sentry: `src/lib/crash.ts` calls `Sentry.init` once crash consent is given (only when the DSN is set, with `environment` from `APP_VARIANT`, errors only, no traces), `crash.capture(err, ctx)` maps to `captureException` with `ctx` as `extra`, and `CrashBoundary` is a `Sentry.ErrorBoundary` whose fallback is the kit's `ErrorState` with Retry. `CrashProvider` (order 40) mounts that boundary app-wide, with its own ThemeProvider since it sits above the ui provider. If you set up with `--with-examples` there's an example at `/examples/crash` and tests for both the capture path and the boundary. `@/lib/crash` keeps the same API whichever option you pick - Sentry is the one service option here, and it's what you want the moment real users can hit a bug you can't reproduce.
## Setup [#setup]
```bash
bun run setup --crash sentry
```
1. Create a project at [sentry.io](https://sentry.io) (platform: React Native), then open \[Settings] → \[Projects] → your project → \[Client Keys (DSN)] and copy the DSN into `.env` as `EXPO_PUBLIC_SENTRY_DSN`. It's public and safe in the client. Without it the SDK isn't initialised, `crash.capture` only logs in dev, and the example shows "Configure EXPO\_PUBLIC\_SENTRY\_DSN".
2. Take the org and project slugs from the project URL and set them as the build-time secrets `SENTRY_ORG` and `SENTRY_PROJECT` - never with an `EXPO_PUBLIC_` prefix.
3. Create a token at \[Settings] → \[Auth Tokens] with the scopes `project:releases` and `org:read`, and set it as `SENTRY_AUTH_TOKEN`. These three are only used at build time, to upload source maps.
4. If you install with bun, run `bun pm trust @sentry/cli` once on an existing tree. bun blocks lifecycle scripts by default and `@sentry/cli` downloads its binary in `postinstall`; without it, source-map uploads fail. The module adds `trustedDependencies: ["@sentry/cli"]` to `package.json`, so a fresh `bun install` handles it for you.
5. The config plugin `["@sentry/react-native", { organization: "", project: "", url }]` is added to `.readynative.json` and applied by `app.config.ts`. The empty org and project make the plugin fall back to the `SENTRY_ORG` and `SENTRY_PROJECT` env vars at build time, so there's nothing to edit. (The plugin id is the package name, not `@sentry/react-native/expo`: same code, and `expo install` would otherwise try to add it to the dynamic config and fail.)
Put `SENTRY_ORG`, `SENTRY_PROJECT` and `SENTRY_AUTH_TOKEN` in EAS (`eas env:set --environment production --visibility sensitive`) so release builds and `eas update` can upload source maps - without them, stack traces stay minified. Use a separate Sentry project (or at least rely on the `environment` tag, which follows `APP_VARIANT`) so production issues aren't buried in your dev noise.
6. Run `bun run doctor` - every row for this module should be green.
The keys, in one place:
| Key | Where to get it |
| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `EXPO_PUBLIC_SENTRY_DSN` | [https://sentry.io/settings/projects/](https://sentry.io/settings/projects/) → project → Client Keys (DSN). Public; safe in the client. |
| `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN` (EAS secrets, never `EXPO_PUBLIC_`) | org/project slugs from the project URL; token from [https://sentry.io/settings/auth-tokens/](https://sentry.io/settings/auth-tokens/) (scopes `project:releases`, `org:read`). Build time only, for source-map upload. |
Dashboards: Issues at `https://sentry.io/organizations//issues/` · Releases (source maps) at `…/releases/` · [Client Keys](https://sentry.io/settings/projects/). Deps: `@sentry/react-native ~7.11.0` - the version Expo SDK 57 pins, so 8.x isn't an option until that pin moves (`npx expo install --fix` downgrades anything newer).
## Usage [#usage]
Report a handled error with context:
```ts
import { crash } from "@/lib/crash";
crash.capture(err, { screen: "checkout", orderId });
```
Wrap a risky subtree so a render error doesn't take the app down:
```tsx
import { CrashBoundary } from "@/lib/crash";
{riskySubtree} ;
```
Tag the signed-in user, so issues say who hit them:
```ts
import * as Sentry from "@sentry/react-native";
Sentry.setUser({ id: user.id });
```
## Consent and PII [#consent-and-pii]
`Sentry.init` runs only once `consent.get().crash` is true (and the DSN is set). Until then no JS or native crash report, session or client report leaves the device - native crash files from a previous run wait for the next init. Withdrawing consent calls `Sentry.close()`; consent again runs a new `Sentry.init`. `beforeSend` / `beforeSendTransaction` still return `null` while crash consent is false, and `crash.capture` is a no-op then. With `consent/consent` that is the EU/EEA, UK, CH, CA, BR until the sheet is answered; the Settings → Privacy toggle takes effect live. `isCrashConfigured` only means the DSN is set; `isCrashRunning()` tells you whether the SDK is initialised.
Everything that does go out passes `scrubEvent()`: `user.ip_address`, `user.email`, `user.username`, `request.headers`, `request.cookies`, `request.query_string` and `request.data` are dropped, the query string is stripped from `request.url`, and emails (`[email]`), JWTs, `Bearer …` tokens and `token=` / `password=` / `api_key=` / `secret=` values are masked in the message, `logentry`, exception values, `extra` (secret-named keys become `[redacted]`) and breadcrumbs. `beforeBreadcrumb` (`scrubBreadcrumb`) strips query strings and fragments from `data.url`, `data.from` and `data.to` on http and navigation breadcrumbs. `user.id` stays so you can count affected users. `sendDefaultPii` is off. `ctx` is scrubbed too, but still keep `crash.capture(err, ctx)` contexts free of personal data.
In Sentry, turn on \[Project Settings] → \[Security & Privacy] → "Prevent Storing of IP Addresses": the connection still reveals the device IP to Sentry, which also derives geo data from it.
## Gotchas [#gotchas]
* There's no Metro plugin (`withSentryConfig` in `metro.config.js`, which the ui module owns), so JS bundles carry no Sentry Debug IDs and uploads match by release and dist instead. Run `npx sentry-expo-upload-sourcemaps dist` after `eas update`; EAS native builds upload through the plugin's gradle and xcode scripts. Add the Metro plugin yourself if you want Debug IDs.
* `Sentry.wrap(RootLayout)` - touch breadcrumbs and the profiler - isn't applied, because core owns `_layout.tsx`. Wrap it there if you want them.
* `bun run setup --crash none` removes everything; with `--with-examples`, `/examples/crash` then shows "Module not installed".
* Expo Go covers JS errors, breadcrumbs and the boundary. Native crashes, offline caching and `Sentry.nativeCrash()` need a dev build.
Check it works (the Examples steps need a tree set up with `--with-examples`):
1. Put a real `EXPO_PUBLIC_SENTRY_DSN` in `.env`, run `bun run start -- -c`, and open the app on a device (Expo Go is fine for JS errors).
2. Examples → "Crash reporting" → **Capture test error**: Issues shows `Error: Test error from /examples/crash` with `extra.source = examples/crash` within about a minute, environment `dev`.
3. Tap **Throw render error**: the card shows "Something went wrong · Render error from /examples/crash" with Retry, the rest of the screen still works, and a second issue appears with `mechanism: react-error-boundary`. Tap **Disarm**, then **Retry**, and "Renders fine until you throw." is back.
4. On a dev build only, add `Sentry.nativeCrash()` to a button and relaunch: a native crash issue appears (symbolication needs `SENTRY_AUTH_TOKEN`).
5. Remove the DSN and restart: the example shows "Configure EXPO\_PUBLIC\_SENTRY\_DSN" and the app still boots.
## Remove it [#remove-it]
While `modules/` exists (a tree set up with `--keep-modules`), `setup` does all of it:
bun run setup --crash none --yes --keep-modules
In a finalized tree `setup` is a stub, so you undo it by hand. Here is everything this module added:
1. **Delete the files** that are still there (the demo screens are gone already unless you set up with `--with-examples`): `src/app/examples/crash.tsx`, `src/lib/__tests__/crash-boundary.test.tsx`, `src/lib/__tests__/crash-sentry.test.ts`, `src/screens/examples/crash-example-screen.tsx`.
2. **Replace, don't delete** `src/lib/crash.ts`: core code imports it, so swap in the no-op version from `modules/crash/none/files/` of a fresh clone of your tier repo - same exports, nothing behind them.
3. **Uninstall the dependencies**: `bun remove @sentry/react-native`.
4. **Drop the config plugin** `@sentry/react-native` from `.readynative.json` → `modules.app.expo.plugins` (that is where `app.config.ts` reads it from), then rebuild the dev build.
5. **Unwrap the provider**: delete `` and its import from `src/providers.tsx`.
6. **Remove the env keys** `EXPO_PUBLIC_SENTRY_DSN` from `.env`, `.env.example` and your EAS environment, and `EXPO_PUBLIC_SENTRY_DSN` from `src/lib/env.ts`.
7. **Update the privacy declarations**: remove this module's entries from `.readynative.json` → `modules.app.expo.ios.privacyManifests`, then re-run `bun run gen:privacy` and revise your store privacy answers.
8. **Check it**: `bun run typecheck` and `bun run lint` point at anything that still imports the removed files; `bun run gen:graph` refreshes `docs/ARCHITECTURE.md`.
## Reference [#reference]
Everything below is generated from `modules/crash/sentry/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --crash sentry
Module id: `crash/sentry` (the default for this category).
### Dependencies [#dependencies]
| Package | Version | Kind |
| ---------------------- | --------- | --------------------------- |
| `@sentry/react-native` | `~7.11.0` | dependency (`expo install`) |
### Config plugins [#config-plugins]
Merged into `app.config.ts` through `.readynative.json` (`modules.app`):
* `@sentry/react-native` (with options)
### Environment keys [#environment-keys]
| Key | Required | Server-only | Example | Docs |
| ------------------------ | -------- | ----------- | ------------------------------------------------ | ------------------------------------------------- |
| `EXPO_PUBLIC_SENTRY_DSN` | yes | no | `https://examplePublicKey@o0.ingest.sentry.io/0` | [dashboard](https://sentry.io/settings/projects/) |
Keys go in `.env` (see `.env.example`). `Required` keys are checked by `bun run doctor`; `Server-only` keys have no `EXPO_PUBLIC_` prefix, are read only by API routes and never reach the bundle.
### Privacy [#privacy]
Play Data safety draft: collects *Crash logs*, *Diagnostics (performance)*, *Device info*; shared with Sentry (processor). Source of truth: [vendor disclosure](https://docs.sentry.io/security-legal-pii/security/mobile-privacy/).
Apple privacy manifest data types (composed into `ios.privacyManifests` by setup):
| Type | Linked to user | Tracking | Purposes |
| --------------------- | -------------- | -------- | ---------------- |
| `CrashData` | no | no | AppFunctionality |
| `PerformanceData` | no | no | AppFunctionality |
| `OtherDiagnosticData` | no | no | AppFunctionality |
### Providers [#providers]
Rendered in `src/providers.tsx` (lower order = outermost):
| Order | Provider | From |
| ----- | --------------- | ------------- |
| 40 | `CrashProvider` | `@/lib/crash` |
### Doctor checks [#doctor-checks]
* env: `EXPO_PUBLIC_SENTRY_DSN`
### After setup [#after-setup]
1. Sentry: create a React Native project at [https://sentry.io](https://sentry.io), copy its DSN (Settings > Projects > \ > Client Keys) into EXPO\_PUBLIC\_SENTRY\_DSN.
2. Sentry: for source maps set EAS secrets SENTRY\_ORG, SENTRY\_PROJECT and SENTRY\_AUTH\_TOKEN ([https://sentry.io/settings/auth-tokens/](https://sentry.io/settings/auth-tokens/), scopes project:releases + org:read); the config plugin uploads on native builds, `npx sentry-expo-upload-sourcemaps dist` after `eas update`.
3. Sentry: call `crash.capture(new Error("test"))` from any screen (or Examples > Crash reporting with `--with-examples`) → the issue appears under Issues within a minute.
### Files [#files]
5 files copied to the project root
* `src/app/examples/crash.tsx`
* `src/lib/__tests__/crash-boundary.test.tsx`
* `src/lib/__tests__/crash-sentry.test.ts`
* `src/lib/crash.ts`
* `src/screens/examples/crash-example-screen.tsx`
# Apollo Client (/docs/features/data/apollo)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/data/apollo - do not edit. */}
Runs in Expo Go; no dev build needed for this module.
Apollo Client 4 is the GraphQL data layer, wired up in `src/lib/apollo.tsx`. I'd pick it when your backend speaks GraphQL - you get a normalised `InMemoryCache`, `useQuery` / `useMutation` and a `cache-and-network` default - and I'd stay on `react-query` or `swr` for REST. Refetch-on-foreground is already handled: the client uses Apollo 4.3's `RefetchEventManager` with the `windowFocus` source swapped for an `AppState` observable, so every active query refetches when the app comes back unless it sets `refetchOn: false`.
## Setup [#setup]
```bash
bun run setup --data apollo
```
1. Put your GraphQL endpoint in `.env` as `EXPO_PUBLIC_GRAPHQL_URL`, for example `https://countries.trevorblades.com/graphql`. It's optional - the example query is skipped until you set it.
2. For an authenticated API, add an `ApolloLink` (`@apollo/client/link/context`) inside `createApolloClient` in `src/lib/apollo.tsx`.
Setup installs `@apollo/client` ^4.3, `graphql` ^16 and `rxjs` ^7.8 (a required peer of v4), and adds `src/lib/apollo.tsx` (`apolloClient` plus `ApolloProvider` at order 20), and a `MockedProvider` test, plus `src/hooks/use-example-query.ts` and the `/examples/query` route and screen with `--with-examples`.
| Key | Required | What it is |
| ------------------------- | -------- | ----------------------- |
| `EXPO_PUBLIC_GRAPHQL_URL` | no | The `HttpLink` endpoint |
## Usage [#usage]
In Apollo 4 the React hooks live in `@apollo/client/react` and the core in `@apollo/client`:
```ts
import { gql } from "@apollo/client";
import { useQuery } from "@apollo/client/react";
const ME = gql`
query Me {
me {
id
name
}
}
`;
const { data, loading, error, refetch } = useQuery(ME);
```
Mutations use `useMutation` from the same entry point. With `--with-examples`, `src/hooks/use-example-query.ts` shows the typed shape with a `TypedDocumentNode`.
For readable error messages in development, import `loadDevMessages` and `loadErrorMessages` from `@apollo/client/dev` and call both in `src/lib/apollo.tsx` behind `__DEV__`.
## Gotchas [#gotchas]
* Expo Go works - it's pure JS, and Metro's package-exports resolution (SDK 53+) handles the v4 entry points.
* On every sign-out and account deletion `wipeLocalData()` runs the `onWipeLocalData` hooks, and `src/lib/apollo.tsx` registers `apolloClient.clearStore()` there, so the next user never sees the previous user's cached responses.
* `InMemoryCache` is lost on restart. To persist it, add `apollo3-cache-persist` (it works with v4) and `await persistCache({ cache, storage })` before rendering, using the adapter from `@/lib/storage`.
* The `online` refetch source is Apollo's default (the browser `online` event, a no-op on native). For offline-aware refetches add `@react-native-community/netinfo` and pass an `online` source to `RefetchEventManager`; `@apollo/client/link/retry` covers transient failures.
* No codegen is wired - the example hand-types its `TypedDocumentNode`. Add `@graphql-codegen/cli` if you want schema-derived types.
* Swapping stacks: `bun run setup --data none` (or `--data react-query` / `--data swr`) removes the files, deps, provider and env key, and with the examples, `/examples/query` falls back to the core "Module not installed" screen.
## Remove it [#remove-it]
While `modules/` exists (a tree set up with `--keep-modules`), `setup` does all of it:
bun run setup --data none --yes --keep-modules
In a finalized tree `setup` is a stub, so you undo it by hand. Here is everything this module added:
1. **Delete the files** that are still there (the demo screens are gone already unless you set up with `--with-examples`): `src/app/examples/query.tsx`, `src/hooks/__tests__/use-example-query.test.tsx`, `src/hooks/use-example-query.ts`, `src/lib/apollo.tsx`, `src/screens/examples/query-example-screen.tsx`.
2. **Replace, don't delete** `src/hooks/use-forecast.ts`: core code imports it, so swap in the no-op version from `modules/data/none/files/` of a fresh clone of your tier repo - same exports, nothing behind them.
3. **Uninstall the dependencies**: `bun remove @apollo/client graphql rxjs`.
4. **Unwrap the provider**: delete `` and its import from `src/providers.tsx`.
5. **Remove the env keys** `EXPO_PUBLIC_GRAPHQL_URL` from `.env`, `.env.example` and your EAS environment, and `EXPO_PUBLIC_GRAPHQL_URL` from `src/lib/env.ts`.
6. **Check it**: `bun run typecheck` and `bun run lint` point at anything that still imports the removed files; `bun run gen:graph` refreshes `docs/ARCHITECTURE.md`.
## Reference [#reference]
Everything below is generated from `modules/data/apollo/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --data apollo
Module id: `data/apollo`.
### Dependencies [#dependencies]
| Package | Version | Kind |
| ---------------- | ---------- | ---------- |
| `@apollo/client` | `^4.3.0` | dependency |
| `graphql` | `^16.14.2` | dependency |
| `rxjs` | `^7.8.2` | dependency |
### Environment keys [#environment-keys]
| Key | Required | Server-only | Example | Docs |
| ------------------------- | -------- | ----------- | -------------------------------------------- | ---- |
| `EXPO_PUBLIC_GRAPHQL_URL` | no | no | `https://countries.trevorblades.com/graphql` | - |
Keys go in `.env` (see `.env.example`). `Required` keys are checked by `bun run doctor`; `Server-only` keys have no `EXPO_PUBLIC_` prefix, are read only by API routes and never reach the bundle.
### Providers [#providers]
Rendered in `src/providers.tsx` (lower order = outermost):
| Order | Provider | From |
| ----- | ---------------- | -------------- |
| 20 | `ApolloProvider` | `@/lib/apollo` |
### Files [#files]
6 files copied to the project root
* `src/app/examples/query.tsx`
* `src/hooks/__tests__/use-example-query.test.tsx`
* `src/hooks/use-example-query.ts`
* `src/hooks/use-forecast.ts`
* `src/lib/apollo.tsx`
* `src/screens/examples/query-example-screen.tsx`
# Data (/docs/features/data)
{/* Generated by apps/docs/scripts/gen-modules.ts; the intro is modules/data/README.md - do not edit. */}
Hey - here's how data works in ReadyNative. This category picks how your app talks to a backend:
the client, the cache, retries and refetch-on-foreground, all behind a provider that setup
registers for you.
Whichever option you pick, the shape is the same: a provider in `src/lib/` at order 20, one hook
per query under `src/hooks/` (with `--with-examples`, the `/examples/query` route to copy from), and a single
`EXPO_PUBLIC_*` base URL in `.env`. The REST options share `src/lib/api/client.ts` (`fetchJson`)
byte for byte, so moving between them doesn't churn your API code. Swap with
`bun run setup --data `.
* **react-query** - the default. Reach for it when you want mutations, invalidation and real
cache control over a REST API.
* **swr** - the same job with a much smaller surface: a string key is a request. Good when you
mostly read.
* **apollo** - pick it when your backend speaks GraphQL.
* **none** - no client at all; call `fetch` yourself or bring your own.
## Options [#options]
One option per category - `--data ` picks it:
bun run setup --data react-query
| Option | Label | Expo Go | Hint |
| ------------------------------ | ------------------------ | ------- | ------------------------------------------------ |
| [`react-query`](./react-query) | TanStack Query (default) | yes | server state + cache · Expo Go OK |
| [`apollo`](./apollo) | Apollo Client | yes | GraphQL client + normalized cache · Expo Go OK |
| [`swr`](./swr) | SWR | yes | stale-while-revalidate hooks · Expo Go OK |
| [`none`](./none) | No data layer | yes | plain fetch; add TanStack Query/Apollo/SWR later |
# No data layer (/docs/features/data/none)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/data/none - do not edit. */}
Runs in Expo Go; no dev build needed for this module.
Nothing is installed - you call `fetch` yourself. I'd keep this when the app has no backend yet, or when you want to bring your own client; everything else in the template works the same, and with `--with-examples`, `/examples/query` shows the core "Module not installed" screen.
## Setup [#setup]
```bash
bun run setup --data none
```
That's it - there's nothing to configure.
## Usage [#usage]
```ts
const res = await fetch("https://api.example.com/me");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const me = (await res.json()) as Me;
```
## Gotchas [#gotchas]
* Add a real data layer whenever you want caching and refetch-on-foreground: `bun run setup --data react-query` (REST), `--data swr` (REST, smaller) or `--data apollo` (GraphQL). Each one brings its own provider and example route.
## Reference [#reference]
Everything below is generated from `modules/data/none/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --data none
Module id: `data/none`.
### Files [#files]
1 files copied to the project root
* `src/hooks/use-forecast.ts`
# TanStack Query (/docs/features/data/react-query)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/data/react-query - do not edit. */}
Runs in Expo Go; no dev build needed for this module.
TanStack Query is the default data layer: a cache, retries and refetch-on-foreground for REST-shaped APIs, wired up in `src/lib/query.tsx`. I'd pick it over `swr` when you want mutations, query invalidation and devtool-grade cache control, and over `apollo` whenever your backend isn't GraphQL. It comes with `src/lib/api/client.ts` - a small `fetchJson` wrapper that resolves relative paths against `EXPO_PUBLIC_API_URL` - so your hooks stay free of URL plumbing.
## Setup [#setup]
```bash
bun run setup --data react-query
```
1. Put your API base URL in `.env` as `EXPO_PUBLIC_API_URL`, for example `https://jsonplaceholder.typicode.com`. It's optional - the example query stays disabled until you set it.
Setup adds `src/lib/query.tsx` (the `queryClient` with retry 1, `staleTime` 30s, `gcTime` 5min, and `QueryProvider` at order 20, refetching on `AppState` foreground), `src/lib/api/client.ts` and a test for the fetch client. With `--with-examples` you also keep the example hook `src/hooks/use-example-query.ts` and the `/examples/query` route with its screen.
| Key | Required | What it is |
| --------------------- | -------- | -------------------------------------------------------- |
| `EXPO_PUBLIC_API_URL` | no | Base URL that relative `fetchJson` paths resolve against |
## Usage [#usage]
Write a hook per query and let `fetchJson` handle the URL and errors:
```ts
import { useQuery } from "@tanstack/react-query";
import { fetchJson } from "@/lib/api/client";
export function useMe() {
return useQuery({ queryKey: ["me"], queryFn: () => fetchJson("/me") });
}
```
`fetchJson` takes a JSON body through `init.json`, times out, and throws `ApiError` on a non-2xx response - so mutations read the same way:
```ts
const save = useMutation({
mutationFn: (input: Draft) => fetchJson("/notes", { method: "POST", json: input }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["notes"] }),
});
```
With `--with-examples`, `src/hooks/use-example-query.ts` is the shape to copy: it gates itself with `enabled: Boolean(env.API_URL)` so a fresh template makes no network calls.
On every sign-out and account deletion `wipeLocalData()` runs the `onWipeLocalData` hooks, and `src/lib/query.tsx` registers `queryClient.clear()` there, so the next user never sees the previous user's cached responses.
## Gotchas [#gotchas]
* Expo Go works - it's pure JS.
* No NetInfo is bundled, so `onlineManager` keeps the library default. Add `@react-native-community/netinfo` and call `onlineManager.setEventListener` in `src/lib/query.tsx` if you want offline-aware retries.
* Swapping stacks: `bun run setup --data none` (or `--data swr` / `--data apollo`) removes the files, dependency, provider and env key. With the examples, `/examples/query` then falls back to the core "Module not installed" screen. `src/lib/api/client.ts` is byte-identical in the `swr` module, so moving between the two doesn't churn it.
## Remove it [#remove-it]
While `modules/` exists (a tree set up with `--keep-modules`), `setup` does all of it:
bun run setup --data none --yes --keep-modules
In a finalized tree `setup` is a stub, so you undo it by hand. Here is everything this module added:
1. **Delete the files** that are still there (the demo screens are gone already unless you set up with `--with-examples`): `src/app/examples/query.tsx`, `src/hooks/use-example-query.ts`, `src/lib/api/__tests__/client.test.ts`, `src/lib/api/client.ts`, `src/lib/query.tsx`, `src/screens/examples/query-example-screen.tsx`.
2. **Replace, don't delete** `src/hooks/use-forecast.ts`: core code imports it, so swap in the no-op version from `modules/data/none/files/` of a fresh clone of your tier repo - same exports, nothing behind them.
3. **Uninstall the dependencies**: `bun remove @tanstack/react-query`.
4. **Unwrap the provider**: delete `` and its import from `src/providers.tsx`.
5. **Remove the env keys** `EXPO_PUBLIC_API_URL` from `.env`, `.env.example` and your EAS environment, and `EXPO_PUBLIC_API_URL` from `src/lib/env.ts`.
6. **Check it**: `bun run typecheck` and `bun run lint` point at anything that still imports the removed files; `bun run gen:graph` refreshes `docs/ARCHITECTURE.md`.
## Reference [#reference]
Everything below is generated from `modules/data/react-query/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --data react-query
Module id: `data/react-query` (the default for this category).
### Dependencies [#dependencies]
| Package | Version | Kind |
| ----------------------- | ---------- | ---------- |
| `@tanstack/react-query` | `^5.103.2` | dependency |
### Environment keys [#environment-keys]
| Key | Required | Server-only | Example | Docs |
| --------------------- | -------- | ----------- | -------------------------------------- | ---- |
| `EXPO_PUBLIC_API_URL` | no | no | `https://jsonplaceholder.typicode.com` | - |
Keys go in `.env` (see `.env.example`). `Required` keys are checked by `bun run doctor`; `Server-only` keys have no `EXPO_PUBLIC_` prefix, are read only by API routes and never reach the bundle.
### Providers [#providers]
Rendered in `src/providers.tsx` (lower order = outermost):
| Order | Provider | From |
| ----- | --------------- | ------------- |
| 20 | `QueryProvider` | `@/lib/query` |
### Files [#files]
7 files copied to the project root
* `src/app/examples/query.tsx`
* `src/hooks/use-example-query.ts`
* `src/hooks/use-forecast.ts`
* `src/lib/api/__tests__/client.test.ts`
* `src/lib/api/client.ts`
* `src/lib/query.tsx`
* `src/screens/examples/query-example-screen.tsx`
# SWR (/docs/features/data/swr)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/data/swr - do not edit. */}
Runs in Expo Go; no dev build needed for this module.
SWR is the small data layer: a stale-while-revalidate cache with a global fetcher, configured in `src/lib/swr.tsx`. I'd pick it over `react-query` when you mostly read data and want the smallest possible API surface - a string key is all a hook needs - and over `apollo` whenever your backend isn't GraphQL. It ships the same `src/lib/api/client.ts` (`fetchJson`) as the TanStack Query module, so switching between the two doesn't churn your API code.
## Setup [#setup]
```bash
bun run setup --data swr
```
1. Put your API base URL in `.env` as `EXPO_PUBLIC_API_URL`, for example `https://jsonplaceholder.typicode.com`. It's optional - the example hook stays paused until you set it.
Setup adds `src/lib/swr.tsx` (`SwrProvider`, order 20: `SWRConfig` with `fetchJson` as the global fetcher, a per-session `Map` cache, revalidate-on-foreground wired to `AppState` through `isVisible` / `initFocus`, one retry, 2s dedupe), `src/lib/api/client.ts` and two tests, plus `src/hooks/use-example-query.ts` and the `/examples/query` route and screen with `--with-examples`.
| Key | Required | What it is |
| --------------------- | -------- | -------------------------------------------------------- |
| `EXPO_PUBLIC_API_URL` | no | Base URL that relative `fetchJson` paths resolve against |
## Usage [#usage]
With the global fetcher, a string key is a path relative to `EXPO_PUBLIC_API_URL`:
```ts
const { data, error, isLoading, mutate } = useSWR("/me");
```
Pass your own fetcher when you want the types inline, and pass `null` as the key to pause a request:
```ts
const q = useSWR(userId ? `/users/${userId}` : null, (path) => fetchJson(path));
```
Mutations come from `swr/mutation`:
```ts
const { trigger } = useSWRMutation("/notes", (key, { arg }: { arg: Draft }) =>
fetchJson(key, { method: "POST", json: arg })
);
```
## Gotchas [#gotchas]
* Expo Go works - it's pure JS.
* On every sign-out and account deletion `wipeLocalData()` runs the `onWipeLocalData` hooks, and `SwrProvider` registers `mutate(() => true, undefined, { revalidate: false })` there (the `mutate` bound to its cache), so the next user never sees the previous user's cached responses. If you persist the cache, clear the persisted copy in the same hook.
* The cache is in memory (`createSwrCache`). To keep it across launches, back the `provider` with `@/lib/storage`: hydrate a `Map` on start and write it back when `AppState` goes to `background`. SWR's "Cache provider" docs cover the shape.
* `isOnline` / `initReconnect` are the library defaults, which means "always online". Add `@react-native-community/netinfo` and set both in `swrConfig` for offline-aware revalidation.
* Swapping stacks: `bun run setup --data none` (or `--data react-query` / `--data apollo`) removes the files, dependency, provider and env key, and with the examples, `/examples/query` falls back to the core "Module not installed" screen.
## Remove it [#remove-it]
While `modules/` exists (a tree set up with `--keep-modules`), `setup` does all of it:
bun run setup --data none --yes --keep-modules
In a finalized tree `setup` is a stub, so you undo it by hand. Here is everything this module added:
1. **Delete the files** that are still there (the demo screens are gone already unless you set up with `--with-examples`): `src/app/examples/query.tsx`, `src/hooks/__tests__/use-example-query.test.tsx`, `src/hooks/use-example-query.ts`, `src/lib/api/__tests__/client.test.ts`, `src/lib/api/client.ts`, `src/lib/swr.tsx`, `src/screens/examples/query-example-screen.tsx`.
2. **Replace, don't delete** `src/hooks/use-forecast.ts`: core code imports it, so swap in the no-op version from `modules/data/none/files/` of a fresh clone of your tier repo - same exports, nothing behind them.
3. **Uninstall the dependencies**: `bun remove swr`.
4. **Unwrap the provider**: delete `` and its import from `src/providers.tsx`.
5. **Remove the env keys** `EXPO_PUBLIC_API_URL` from `.env`, `.env.example` and your EAS environment, and `EXPO_PUBLIC_API_URL` from `src/lib/env.ts`.
6. **Check it**: `bun run typecheck` and `bun run lint` point at anything that still imports the removed files; `bun run gen:graph` refreshes `docs/ARCHITECTURE.md`.
## Reference [#reference]
Everything below is generated from `modules/data/swr/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --data swr
Module id: `data/swr`.
### Dependencies [#dependencies]
| Package | Version | Kind |
| ------- | -------- | ---------- |
| `swr` | `^2.3.0` | dependency |
### Environment keys [#environment-keys]
| Key | Required | Server-only | Example | Docs |
| --------------------- | -------- | ----------- | -------------------------------------- | ---- |
| `EXPO_PUBLIC_API_URL` | no | no | `https://jsonplaceholder.typicode.com` | - |
Keys go in `.env` (see `.env.example`). `Required` keys are checked by `bun run doctor`; `Server-only` keys have no `EXPO_PUBLIC_` prefix, are read only by API routes and never reach the bundle.
### Providers [#providers]
Rendered in `src/providers.tsx` (lower order = outermost):
| Order | Provider | From |
| ----- | ------------- | ----------- |
| 20 | `SwrProvider` | `@/lib/swr` |
### Files [#files]
8 files copied to the project root
* `src/app/examples/query.tsx`
* `src/hooks/__tests__/use-example-query.test.tsx`
* `src/hooks/use-example-query.ts`
* `src/hooks/use-forecast.ts`
* `src/lib/api/__tests__/client.test.ts`
* `src/lib/api/client.ts`
* `src/lib/swr.tsx`
* `src/screens/examples/query-example-screen.tsx`
# Forms (/docs/features/forms)
{/* Generated by apps/docs/scripts/gen-modules.ts; the intro is modules/forms/README.md - do not edit. */}
Hey - here's how forms work in ReadyNative. This category decides whether you get a form library
on top of the kit's `Input` (`@/components/ui`), which already renders a label, a value and an error message.
Either way the inputs are the same kit primitives and validation messages land in the same
`error` prop, so a form written today keeps looking right if you switch stacks later. Swap with
`bun run setup --forms `.
* **react-hook-form** - the default. Pick it as soon as a form has more than two fields or any
validation worth naming: you get a zod schema per form and a `Field` component that binds the
kit's `Input` through `Controller`.
* **none** - controlled `Input`s with `useState`. Fine for a search box or a rename
dialog.
## Options [#options]
One option per category - `--forms ` picks it:
bun run setup --forms react-hook-form
| Option | Label | Expo Go | Hint |
| -------------------------------------- | ------------------------- | ------- | ----------------------------------------------------- |
| [`react-hook-form`](./react-hook-form) | React Hook Form (default) | yes | Form + Field on @/ui Input, zod resolver · Expo Go OK |
| [`none`](./none) | No forms library | yes | controlled @/ui Inputs with useState |
# No forms library (/docs/features/forms/none)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/forms/none - do not edit. */}
Runs in Expo Go; no dev build needed for this module.
Nothing is installed - you write controlled `Input`s from `@/components/ui` with `useState` and validate by hand. I'd keep this while your forms are one or two fields (a search box, a rename dialog); it's one less dependency and the kit's `Input` already renders labels and errors. With `--with-examples`, `/examples/form` shows the core "Module not installed" screen.
## Setup [#setup]
```bash
bun run setup --forms none
```
That's it - there's nothing to configure.
## Usage [#usage]
```tsx
import { useState } from "react";
import { Button, Input } from "@/components/ui";
const [email, setEmail] = useState("");
const error = email.includes("@") ? undefined : "Enter a valid email";
;
Save ;
```
## Gotchas [#gotchas]
* Once a form grows past a couple of fields, or you want a schema, run `bun run setup --forms react-hook-form`. It adds a `Field` component that binds the same `Input` through `Controller`.
## Reference [#reference]
Everything below is generated from `modules/forms/none/module.json` - the same file `bun run setup` reads, so it is what actually lands in your repo.
### Install [#install]
bun run setup --forms none
Module id: `forms/none`.
# React Hook Form (/docs/features/forms/react-hook-form)
{/* Generated by apps/docs/scripts/gen-modules.ts from modules/forms/react-hook-form - do not edit. */}
Runs in Expo Go; no dev build needed for this module.
React Hook Form plus a zod resolver, wrapped in two small components under `src/components/form/` so your screens stay on `@/components/ui`. I'd pick it over `forms/none` as soon as a form has more than two fields or any validation worth naming - you get uncontrolled inputs, one schema per form and error messages wired into the kit's `Input` for free. `Field` is the contract here: it binds the kit's `Input` through `Controller` and shows the validation message as its `error` prop, so you never style an error state by hand.
## Setup [#setup]
```bash
bun run setup --forms react-hook-form
```
That's it - setup adds `src/components/form/form.tsx` (`