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 --clean

expo 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:

ConsequenceWhat 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 truthAnything 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.

When you must prebuild

ChangePrebuild?New build?
Screens, hooks, styles, routes, translationsNoNo — Metro reloads it
Adding a dependency that ships a config pluginYesYes
scheme, ios.bundleIdentifier, android.package, versionYesYes
Permission strings or entitlements (the plugins array)YesYes
Toggling apple in EXPO_PUBLIC_AUTH_OAUTH_PROVIDERSYes — cleanYes
Any other EXPO_PUBLIC_* valueNoYes — 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 injects
return 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:

KeyValue in the kitChange before shipping?
nameMakerkit NativeYes — this is the springboard/launcher label
slugmakerkit-nativeYes if you use EAS; it identifies the project
schememkkitOptional, but it is the OAuth redirect scheme
version0.1.0Yes — the user-visible marketing version
ios.bundleIdentifierdev.makerkit.appYes, mandatory
android.packagedev.makerkit.appYes, 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.

PluginWhat it contributes to the native project
expo-routerDeep-link and entry-point wiring for the file-based router
expo-secure-storeKeychain / Keystore access, where the Supabase session lives
expo-localizationDevice-locale APIs, used to pick the initial language
expo-image-pickerThe 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:

BuildNODE_ENVFiles loaded, highest priority first
expo start, expo run:ios, expo run:androiddevelopment.env.development.local, .env.local, .env.development, .env
expo run:ios --configuration Release, expo run:android --variant release, eas buildproduction.env.production.local, .env.local, .env.production, .env

Pointing a build at a real backend

Four variables move the app off your laptop:

VariableProduction value
EXPO_PUBLIC_SUPABASE_URLYour hosted Supabase project URL
EXPO_PUBLIC_SUPABASE_PUBLIC_KEYThat project's publishable key
EXPO_PUBLIC_API_BASE_URLThe origin of your deployed apps/web, which serves /api/v1/*
EXPO_PUBLIC_SITE_URLThe 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 simulator
pnpm android:native # expo run:android — debug build, installs on the emulator

Both 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/native
npx expo run:ios --configuration Release
npx expo run:android --variant release

That 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

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 createWhy
An Expo account and eas-clipnpm dlx eas-cli login
extra.eas.projectId and owner in the app configWritten by eas init. It edits app.json, which the dynamic app.config.ts spreads through unchanged.
apps/native/eas.jsonWritten by eas build:configure. Defines your build profiles — typically development (a dev client), preview (internal distribution), production (store).
Per-profile environmentEAS 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 credentialsApple 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.

RequirementTriggered byStatus in this repo
Sign in with Apple (App Store 4.8)Offering Google — or any social login — on iOSImplemented, 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 presentNot implemented. A hard blocker for iOS submission.
In-app account deletion (App Store 5.1.1(v), Play)Any in-app account creationImplemented and OTP-confirmed. Must stay reachable in-app — a link out to web is not accepted.
Privacy labels / Play Data SafetyOAuth collecting email or nameDeclared by you at submission. Revisit whenever you add a provider.

Pre-flight checklist

Before your first submission, in roughly this order:

  1. Identity — set ios.bundleIdentifier and android.package to your own reverse-DNS identifier in app.json, and set name, slug and version. Work through the identity and scheme rows in docs/native/config-requirements.mdoc for what each change forces elsewhere.
  2. Permission strings — rewrite photosPermission in the expo-image-picker plugin entry so the OS dialog names your product and states your reason.
  3. 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.
  4. Verify https — confirm EXPO_PUBLIC_API_BASE_URL starts with https://, and that no loopback address survives in EXPO_PUBLIC_SITE_URL.
  5. Apple sign-in (iOS) — add apple to EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS, create the App ID with the Sign in with Apple capability, enable the provider in Supabase local and hosted with client_id set to your bundle identifier, then pnpm prebuild:native. Read the token-revocation warning above before you do.
  6. Tighten the auth rate limits[auth.rate_limit] email_sent still 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 in docs/native/config-requirements.mdoc.
  7. 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.
  8. Icons and splashapp.json declares no icon and no splash, and android.adaptiveIcon carries only a backgroundColor. apps/native/assets/ contains images/logo.png (used in-app by features/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.
  9. Gates — run pnpm typecheck, pnpm lint, pnpm format, and pnpm doctor:native. Doctor's duplicate-dependency check flags react / react-dom across the pnpm-isolated trees; that one is a known false positive, covered in docs/native/troubleshooting.mdoc.
  10. 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.