# 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.

*Canonical: https://makerkit.dev/docs/react-native-supabase/building-shipping*

---

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](#store-submission-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:

```bash
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:

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

{% alert type="warning" title="`prebuild` in this repo is always a clean prebuild" %}
`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.
{% /alert %}

### 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](#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`:

```ts
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`:

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

```json
[
  "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` |

{% alert type="warning" title="The kit ships no production env file" %}
`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.
{% /alert %}

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

```ts
// 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

```bash
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:

```bash
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

{% alert type="warning" title="EAS is not configured in this repository" %}
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.
{% /alert %}

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

{% alert type="warning" title="Apple token revocation is not built" %}
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.
{% /alert %}

## 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 splash** — `app.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.
