Building and shipping the Expo app | Expo Supabase SaaS Kit
Take the native app from a working dev build to a binary in the App Store and Play Store: prebuild, app identity, per-environment configuration, local and EAS builds, and the store gates you must clear first.
This page covers everything between "the app runs on my simulator" and "the app is in review". It explains how the native projects are generated, what a build bakes in that a running dev server does not, and which store requirements the kit already satisfies versus the ones you must finish yourself. Running the app day to day is docs/native/running-locally.mdoc; this page is about producing a shippable binary.
Read the store gates section before you plan a release date. One of them is not implemented in this repository and blocks an iOS submission.
The build model
apps/native is a bare-workflow-capable Expo app. It depends on native modules that ship config plugins (expo-secure-store, expo-image-picker, expo-apple-authentication, expo-dev-client) and on expo-dev-client itself, so there is no Expo Go path — Expo Go carries a fixed set of native modules and cannot load this app's. Every device or simulator you test on runs a build you produced.
The native projects are generated, not authored:
pnpm prebuild:native # → pnpm --filter native-app prebuild → expo prebuild --cleanexpo prebuild reads apps/native/app.json and apps/native/app.config.ts and writes apps/native/ios/ and apps/native/android/ from them. Both directories are listed in apps/native/.gitignore and are not committed. Three consequences follow, and they are the reason the kit is set up this way:
| Consequence | What it means for you |
|---|---|
A fresh clone has no ios/ or android/ | Nothing to do. expo run:ios / expo run:android call ensureNativeProjectAsync, which prebuilds the missing platform automatically on first run. |
| The app config is the source of truth | Anything that must survive a regeneration — a permission string, an entitlement, a bundle identifier — belongs in app.json or app.config.ts, or in a config plugin. |
Never hand-edit ios/ or android/ | Edits there are silently discarded by the next prebuild, and there is no diff to review because the folders are untracked. If Xcode or Android Studio is the only place you can express a change, write a config plugin for it. |
apps/native/package.json defines "prebuild": "expo prebuild --clean", which deletes ios/ and android/ before regenerating them. That is deliberate: a merge prebuild (plain npx expo prebuild) keeps files it did not generate this run, which is exactly how a stale entitlement survives a config change. There is no non-clean script — if you want a merge prebuild you have to type it out.
When you must prebuild
| Change | Prebuild? | New build? |
|---|---|---|
| Screens, hooks, styles, routes, translations | No | No — Metro reloads it |
| Adding a dependency that ships a config plugin | Yes | Yes |
scheme, ios.bundleIdentifier, android.package, version | Yes | Yes |
Permission strings or entitlements (the plugins array) | Yes | Yes |
Toggling apple in EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS | Yes — clean | Yes |
Any other EXPO_PUBLIC_* value | No | Yes — see Environment is per build |
The Apple row is the one that bites. expo-apple-authentication is a permanent dependency (the dormant sign-in code imports it), and Expo autolinks its config plugin at prebuild, so the com.apple.developer.applesignin entitlement is injected just by the package being installed — the plugins array in app.json has nothing to do with it. app.config.ts therefore strips the entitlement whenever the flag does not contain apple:
const appleEnabled = (process.env.EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS ?? 'google') .split(',') .map((provider: string) => provider.trim()) .includes('apple');// ...if (appleEnabled) return merged;// Apple off → remove the entitlement Expo autolinking injectsreturn withEntitlementsPlist(merged, (cfg) => { delete cfg.modResults['com.apple.developer.applesignin']; return cfg;});A merge prebuild after toggling that flag keeps the previous .entitlements file, so the entitlement stays on after you turned Apple off — and while it is on, even a simulator build demands a paid Apple Developer signing team. Use pnpm prebuild:native, which is already --clean.
App identity
Everything that names your app to the operating system and the stores lives in apps/native/app.json:
| Key | Value in the kit | Change before shipping? |
|---|---|---|
name | Makerkit Native | Yes — this is the springboard/launcher label |
slug | makerkit-native | Yes if you use EAS; it identifies the project |
scheme | mkkit | Optional, but it is the OAuth redirect scheme |
version | 0.1.0 | Yes — the user-visible marketing version |
ios.bundleIdentifier | dev.makerkit.app | Yes, mandatory |
android.package | dev.makerkit.app | Yes, mandatory |
dev.makerkit.app is Makerkit's identifier. You cannot submit under it, and neither store will let two apps share one. Pick your own reverse-DNS identifier before you create anything in App Store Connect or the Play Console, because both consoles treat the identifier as immutable once an app record exists.
Changing app identity has a wide blast radius: an Apple App ID, redirect URIs in the Google and Apple consoles, Universal Links association files, and store identity all key off it. The scheme is similarly load-bearing — the native OAuth redirectTo is makeRedirectUri({ scheme }), so Supabase's additional_redirect_urls allow-list (local and hosted) is derived from it. The full trigger table for both is in docs/native/config-requirements.mdoc; do not change either value without reading it.
Config plugins
app.json declares four plugins. Expo also autolinks plugins from installed packages that ship one (expo-apple-authentication above, expo-dev-client), so this array is not the complete list of what runs at prebuild.
| Plugin | What it contributes to the native project |
|---|---|
expo-router | Deep-link and entry-point wiring for the file-based router |
expo-secure-store | Keychain / Keystore access, where the Supabase session lives |
expo-localization | Device-locale APIs, used to pick the initial language |
expo-image-picker | The photo-library picker used by avatar and team-logo uploads, plus its permission strings |
The image-picker entry is the one with user-visible output:
[ "expo-image-picker", { "photosPermission": "Allow $(PRODUCT_NAME) to access your photos so you can set a profile picture.", "cameraPermission": false, "microphonePermission": false }]photosPermission becomes NSPhotoLibraryUsageDescription in Info.plist — the exact sentence iOS shows in the permission dialog, and a string App Review reads. $(PRODUCT_NAME) expands to the Xcode product name, so it inherits name from app.json; rewrite the sentence to match your product's voice, and keep it specific about why you need the library. The two false values suppress the camera and microphone permissions entirely, which is correct — the app never asks for either, and declaring an unused permission invites review questions and a larger Play Data Safety declaration.
app.config.ts is the dynamic layer over app.json. It keeps all static config in app.json and computes only what must vary at prebuild time, which today is the Apple entitlement strip described above. Add dynamic config there; add static config to app.json.
Environment is per build
EXPO_PUBLIC_* variables are inlined into the JS bundle at build time. They are not read at startup. Changing a value and restarting an installed app does nothing — dev, staging and production each need their own build, and a value you fix after shipping reaches users only through a store update.
Which files are read depends on NODE_ENV, which the Expo CLI sets from the build configuration. Verified against @expo/env and the CLI's run:ios / run:android commands:
| Build | NODE_ENV | Files loaded, highest priority first |
|---|---|---|
expo start, expo run:ios, expo run:android | development | .env.development.local, .env.local, .env.development, .env |
expo run:ios --configuration Release, expo run:android --variant release, eas build | production | .env.production.local, .env.local, .env.production, .env |
apps/native/.env.development is the only env file in the repository. A release build therefore loads none of its values, and apps/native/features/core/supabase.ts throws at module scope — the app crashes on launch with "Missing EXPO_PUBLIC_SUPABASE_URL or EXPO_PUBLIC_SUPABASE_PUBLIC_KEY". Create apps/native/.env.production (or supply the values to your build service) before your first release build. The root .gitignore only ignores .env*.local, so a committed .env.production is tracked — which is fine, because every EXPO_PUBLIC_* value is public by definition. Never put a secret in one.
Pointing a build at a real backend
Four variables move the app off your laptop:
| Variable | Production value |
|---|---|
EXPO_PUBLIC_SUPABASE_URL | Your hosted Supabase project URL |
EXPO_PUBLIC_SUPABASE_PUBLIC_KEY | That project's publishable key |
EXPO_PUBLIC_API_BASE_URL | The origin of your deployed apps/web, which serves /api/v1/* |
EXPO_PUBLIC_SITE_URL | The same deployed web app — where outbound links (terms, privacy) open |
EXPO_PUBLIC_API_BASE_URL must be https:// in a production build. api() in apps/native/features/core/api-client.ts enforces it rather than trusting configuration:
// Never send bearer tokens over cleartext in a production build.if (!__DEV__ && !API_BASE_URL.startsWith('https://')) { throw new Error( 'EXPO_PUBLIC_API_BASE_URL must use https:// in a production build.', );}Every request carries the user's Supabase access token in an Authorization header, so cleartext would leak a live session to anything on the network path. A release build with an http:// base URL does not warn — every data request throws, and the app renders error states everywhere.
The loopback defaults are dead in a release build for the same class of reason. resolveDevHost() in apps/native/features/core/dev-host.ts — the helper that rewrites 127.0.0.1 to 10.0.2.2 for the Android emulator — is guarded by __DEV__ and never fires in production. A http://localhost:3000 left in EXPO_PUBLIC_SITE_URL produces terms and privacy links that open nothing on a real device.
Remaining flags (EXPO_PUBLIC_AUTH_*, EXPO_PUBLIC_PASSWORD_REQUIRE_*, the deletion toggles) are documented in docs/native/config-requirements.mdoc. Mirror them against the NEXT_PUBLIC_* values your web deployment uses — a mismatch is silent, and the classic symptom is native accepting a password web rejects.
Building
Locally
pnpm ios:native # expo run:ios — debug build, installs on the simulatorpnpm android:native # expo run:android — debug build, installs on the emulatorBoth scripts regenerate the i18n catalogue first (pnpm i18n), then prebuild the platform if it is missing. For a release binary from the same CLI:
cd apps/nativenpx expo run:ios --configuration Releasenpx expo run:android --variant releaseThat is enough to confirm a production bundle boots and reaches your backend, and it is the cheapest way to catch the two errors above. It is not enough to submit: an App Store build needs an archive with a distribution certificate and provisioning profile, and a Play build needs a signed .aab. For those, prebuild first and then use the platform tools directly — Xcode's Product → Archive against apps/native/ios/, and ./gradlew bundleRelease in apps/native/android/ with a release keystore configured. Remember that both directories are regenerated: keep your keystore and any signing configuration outside them (apps/native/.gitignore already excludes *.jks, *.p12, *.p8, *.key and *.mobileprovision from the repository).
With EAS
There is no eas.json anywhere in the repo, no extra.eas.projectId or owner in apps/native/app.json, and eas-cli is not a dependency. Nothing here is wired up to Expo Application Services — adopting it is your setup step, not a switch you flip.
EAS is worth adopting if you do not want to maintain a Mac with Xcode, or you want credentials managed for you. What you have to create:
| You must create | Why |
|---|---|
An Expo account and eas-cli | pnpm dlx eas-cli login |
extra.eas.projectId and owner in the app config | Written by eas init. It edits app.json, which the dynamic app.config.ts spreads through unchanged. |
apps/native/eas.json | Written by eas build:configure. Defines your build profiles — typically development (a dev client), preview (internal distribution), production (store). |
| Per-profile environment | EAS builds from your git contents and runs with NODE_ENV=production, so either commit .env.production or declare each EXPO_PUBLIC_* in the profile's env block. Decide one and stick to it — two sources for the same value is how a staging build ships pointed at production. |
| Store credentials | Apple Developer Program membership and an App Store Connect app record; a Play Console account and an upload key. |
Builds and submissions are then eas build --platform ios --profile production and eas submit --platform ios. The identity, environment, prebuild and store-gate rules on this page apply unchanged — EAS runs the same expo prebuild on its own machines.
Store submission gates
These gate shipping rather than adding features. docs/native/covered-features.mdoc is the authority on status; docs/native/config-requirements.mdoc has the configuration detail for each.
| Requirement | Triggered by | Status in this repo |
|---|---|---|
| Sign in with Apple (App Store 4.8) | Offering Google — or any social login — on iOS | Implemented, off by default. Add apple to EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS, run a clean prebuild, and expect to need a paid Apple Developer team even for simulator builds. |
| Apple token revocation (App Store 5.1.1(v)) | Sign in with Apple and in-app account deletion both present | Not implemented. A hard blocker for iOS submission. |
| In-app account deletion (App Store 5.1.1(v), Play) | Any in-app account creation | Implemented and OTP-confirmed. Must stay reachable in-app — a link out to web is not accepted. |
| Privacy labels / Play Data Safety | OAuth collecting email or name | Declared by you at submission. Revisit whenever you add a provider. |
Apple requires that deleting an account also revokes the Apple token, and Supabase does not do it for you. It needs an Apple Services ID and .p8 key, a server-side authorization-code exchange at sign-in to capture a refresh token, and a revoke call during deletion — none of which exists in this repository.
The two features that trigger it are the two you are most likely to ship together: guideline 4.8 pushes you to offer Sign in with Apple, and 5.1.1(v) requires in-app deletion. Plan this work before you plan an iOS release. Android is unaffected.
Pre-flight checklist
Before your first submission, in roughly this order:
- Identity — set
ios.bundleIdentifierandandroid.packageto your own reverse-DNS identifier inapp.json, and setname,slugandversion. Work through the identity andschemerows indocs/native/config-requirements.mdocfor what each change forces elsewhere. - Permission strings — rewrite
photosPermissionin theexpo-image-pickerplugin entry so the OS dialog names your product and states your reason. - Environment — create
apps/native/.env.production(or the EAS equivalent) with your hosted Supabase URL and key, your deployed API base URL and site URL, and every flag mirrored against your web deployment. - Verify https — confirm
EXPO_PUBLIC_API_BASE_URLstarts withhttps://, and that no loopback address survives inEXPO_PUBLIC_SITE_URL. - Apple sign-in (iOS) — add
appletoEXPO_PUBLIC_AUTH_OAUTH_PROVIDERS, create the App ID with the Sign in with Apple capability, enable the provider in Supabase local and hosted withclient_idset to your bundle identifier, thenpnpm prebuild:native. Read the token-revocation warning above before you do. - Tighten the auth rate limits —
[auth.rate_limit] email_sentstill carries upstream's default of 1000/hour. Native sign-up ships no captcha by design, so rate limiting is the only bound on email flooding. It throttles web's transactional email too, so pick the number deliberately. Detail indocs/native/config-requirements.mdoc. - Custom SMTP — required before any native email flow works against a hosted project; the built-in provider caps auth email at a handful per hour project-wide. Sign-up confirmation, password reset and invitations all depend on it.
- Icons and splash —
app.jsondeclares noiconand nosplash, andandroid.adaptiveIconcarries only abackgroundColor.apps/native/assets/containsimages/logo.png(used in-app byfeatures/makerkit/components/app-logo.tsx) and four OAuth provider logos — no launcher icon. Builds today ship Expo's default icon and splash screen, which neither store will accept as your brand. Add the assets and the config keys, then prebuild. - Gates — run
pnpm typecheck,pnpm lint,pnpm format, andpnpm doctor:native. Doctor's duplicate-dependency check flagsreact/react-domacross the pnpm-isolated trees; that one is a known false positive, covered indocs/native/troubleshooting.mdoc. - Smoke-test the release build — install a Release/release-variant build on a real device and sign in, upload an avatar and delete a test account. Debug builds hide every
__DEV__-guarded difference on this page.
There is no over-the-air update channel
expo-updates is not installed and no update URL is configured, so the app has no OTA channel: every change reaches users through a store release, JS-only changes included. This is why web's version-updater feature has no native counterpart (see docs/native/covered-features.mdoc), and it is the practical argument for the architecture in docs/native/introduction.mdoc — business rules live behind /api/v1 on a server you can redeploy today, precisely because you cannot assume every installed binary is running this week's code.
Adopting expo-updates is possible and is a normal EAS Update setup, but it is a feature you add, not one you enable. It also does not change the store gates above: native code, permissions and entitlements can only change in a new binary.