# Running the Expo app locally | Expo Supabase SaaS Kit

> Get the native app running on an iOS simulator, an Android emulator or a physical device against your local Supabase stack, with the full command and environment reference.

*Canonical: https://makerkit.dev/docs/react-native-supabase/running-locally*

---

This page takes you from a cloned repository to the Expo app running on a simulator, talking to the same local Supabase stack and the same Next.js server that `apps/web` uses. It covers the command set, the environment files, and the one thing that trips people up most — which host each device class can actually reach.

## Before you start

The native app is a **client of your local backend**, not a self-contained stack. Three things must already be true.

| Requirement | How to satisfy it | Why |
| --- | --- | --- |
| Local Supabase is running | `pnpm run supabase:web:start` | The app authenticates and reads directly against `http://127.0.0.1:54321`. |
| The Next.js app is running | `pnpm dev`, or `pnpm --filter web dev` (port 3000) | Every mutation and most reads go through `/api/v1/*` on the web app — see `docs/native/data-fetching.mdoc`. |
| A native toolchain is installed | Xcode **plus its iOS platform** (`xcodebuild -downloadPlatform iOS`) for iOS, Android Studio (with an emulator image and `ANDROID_HOME` set) for Android | The kit builds a real native binary. There is no browser fallback. |

Installing Xcode alone is not enough for iOS: the iOS platform is a separate download, and without it `xcodebuild` rejects every simulator — even ones that boot fine on their own. See [the simulator exists but Xcode will not build to it](/docs/next-supabase-turbo/native/troubleshooting#the-simulator-exists-but-xcode-will-not-build-to-it) for how that failure presents.

You also need Node and pnpm. The root `package.json` declares `engines.node >= 20.10.0` and pins `packageManager` to `pnpm@11.20.0`; the `preinstall` script (`tooling/scripts/src/requirements.mjs`) additionally hard-fails on Node below 18.18 or pnpm below major 9.

{% alert type="warning" title="`pnpm dev` does not start the native app" %}
The root `dev` script is `turbo dev --parallel`, and `apps/native/package.json` **defines no `dev` script** — so Turborepo has nothing to run for it. The same is true of `build` and `test`. This is deliberate: the native "dev server" is Metro (`expo start`) and the native "build" is `expo prebuild` plus Xcode/Gradle, neither of which fits a parallel Turbo pipeline. Start native explicitly, in its own terminal.
{% /alert %}

## The short path

From the repository root, in three terminals:

```bash
# 1 — backend
pnpm run supabase:web:start

# 2 — web app (serves /api/v1 and the pages native links out to)
pnpm dev                # or: pnpm --filter web dev

# 3 — native, first run: compiles and installs a development build
pnpm ios:native        # or: pnpm android:native
```

The first `ios:native` / `android:native` takes several minutes: `ios/` and `android/` are gitignored, so Expo generates them (prebuild) and then compiles. On every subsequent run, once the development build is installed on the simulator, `pnpm start:native` is enough — it only starts Metro and connects to the already-installed app.

## Command reference

Every native script exists twice: as a package script in `apps/native/package.json`, and as a root alias in the repo's `package.json` so you never have to `cd`. Both columns run the same thing.

| Root alias | In `apps/native` | Runs | Reach for it when |
| --- | --- | --- | --- |
| `pnpm start:native` | `pnpm start` | `pnpm i18n && expo start` | Daily loop. Metro only — the build is already installed. |
| `pnpm start:native:clear` | `pnpm start:clear` | `pnpm i18n && expo start --clear` | [Metro contradicts what's on disk](/docs/next-supabase-turbo/native/troubleshooting#metro-serves-something-stale) ("unable to resolve" a package that is installed) after a dependency change or branch switch. |
| `pnpm ios:native` | `pnpm ios` | `pnpm i18n && expo run:ios` | First run, or after any change that needs a new native binary. Prebuilds if `ios/` is missing, compiles, installs, starts Metro. Fails on [a missing iOS platform](/docs/next-supabase-turbo/native/troubleshooting#the-simulator-exists-but-xcode-will-not-build-to-it) or [signing](/docs/next-supabase-turbo/native/troubleshooting#ios-build-fails-with-no-code-signing-certificates). |
| `pnpm android:native` | `pnpm android` | `pnpm i18n && expo run:android` | Same, for the Android emulator or a connected device. |
| `pnpm prebuild:native` | `pnpm prebuild` | `expo prebuild --clean` | After editing `app.json` / `app.config.ts`, or adding a package with a config plugin. `--clean` deletes and regenerates `ios/` and `android/`. |
| `pnpm i18n:native` | `pnpm i18n` | regenerates `apps/native/lib/i18n/messages.generated.ts` | Rarely by hand — it already runs before `start`, `ios` and `android`. |
| `pnpm i18n:native:check` | — | same script without `--write` | Verifying the catalogue is in sync without rewriting it. Exits non-zero if stale. |
| `pnpm doctor:native` | `pnpm doctor` | `expo-doctor` | Diagnosing a native dependency graph problem. Read [the duplicate-React caveat](/docs/next-supabase-turbo/native/troubleshooting#expo-doctor-flags-react-react-dom-as-duplicates) first — one of its checks reports a known false positive here. |
| `pnpm deps:native:check` | `pnpm deps:check` | `expo install --check` | Checking installed versions against what the Expo SDK expects. There is deliberately no `--fix` alias. |
| `pnpm clean:native:cache` | `pnpm clean:cache` | `git clean -xdf .turbo .expo node_modules/.cache` | [`typecheck` reports errors that don't match the code](/docs/next-supabase-turbo/native/troubleshooting#typecheck-disagrees-with-the-code), or route types are stale after moving a route. Keeps `node_modules`. |
| — | `pnpm --filter native-app clean` | `git clean -xdf .turbo .expo node_modules` | Nuclear. Requires a reinstall afterwards. |
| — | `pnpm --filter native-app typecheck` | i18n check + `tsc --noEmit` | A native-only type loop. The root `pnpm typecheck` already includes native and `@kit/mobile-ui`. |

Lint and format are repo-wide and already cover native: `pnpm lint:fix` and `pnpm format:fix` at the root need no native-specific variant.

{% alert type="warning" title="Never run `expo install --fix`" %}
Every native dependency in this repo is pinned through the `expo` named catalog in `pnpm-workspace.yaml`. `expo install --fix` rewrites each `catalog:expo` reference to a literal version, destroying the pinning convention across `apps/native` and `packages/mobile-ui`. Use `pnpm deps:native:check` to *see* mismatches, then hand-edit the catalog. `docs/native/config-requirements.mdoc` covers the SDK-bump procedure, and [the catalog pinning was rewritten](/docs/next-supabase-turbo/native/troubleshooting#the-catalog-pinning-was-rewritten) covers the recovery if you already ran it.
{% /alert %}

## Environment files

Expo loads `.env` files by `NODE_ENV` mode, highest priority first. For `expo start` (mode `development`) the order is:

```
.env.development.local   →   .env.local   →   .env.development   →   .env
```

The first file to define a key wins, and real shell environment variables beat all of them. Only `apps/native/.env.development` is committed; everything ending in `.local` is gitignored at the repo root (`.env*.local`).

That split is the whole workflow:

- **`apps/native/.env.development`** — committed, shared, safe. It mirrors the safe-to-commit half of `apps/web/.env.development`.
- **`apps/native/.env.development.local`** — yours alone. Create it when you need a personal override (a LAN IP, a different Supabase project). Copy only the keys you are changing; the rest still fall through to the committed file.

{% alert type="warning" title="`EXPO_PUBLIC_*` is public and build-time" %}
`babel-preset-expo` inlines every `process.env.EXPO_PUBLIC_*` reference into the JavaScript bundle at transform time — the same security model as web's `NEXT_PUBLIC_*`. Two consequences: **never** put a secret in these files, and a value change does nothing to an already-installed release build. In development, restart Metro after editing an env file (`pnpm start:native:clear` if the old value survives). A missing value fails loudly — see [environment variable errors](/docs/next-supabase-turbo/native/troubleshooting#environment-variable-errors) for the three messages and which file each one names.
{% /alert %}

### What each variable does

Every key in `apps/native/.env.development`, verified against the code that reads it:

| Variable | Committed value | Read by | Effect |
| --- | --- | --- | --- |
| `EXPO_PUBLIC_PRODUCT_NAME` | `Makerkit` | `features/core/config/app.config.ts` | Product name; used as the TOTP issuer at MFA enrollment, so it labels the entry in authenticator apps. Falls back to `app.json`'s `name`. |
| `EXPO_PUBLIC_SITE_URL` | `http://localhost:3000` | `features/core/config/app.config.ts` | The public web app. Native links out to it (terms and privacy pages), so it must be reachable **from the device**. |
| `EXPO_PUBLIC_SUPABASE_URL` | `http://127.0.0.1:54321` | `features/core/supabase.ts` | Supabase API root. Missing → the module throws at import time with an explicit message. |
| `EXPO_PUBLIC_SUPABASE_PUBLIC_KEY` | local publishable key | `features/core/supabase.ts` | The publishable (anon) key. Same key the web app uses locally. |
| `EXPO_PUBLIC_API_BASE_URL` | `http://localhost:3000` | `features/core/api-client.ts` | Root for `/api/v1/*`. `api()` throws if unset, and refuses a non-`https://` value in a production build so bearer tokens never travel in cleartext. |
| `EXPO_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION` | `true` | `features/core/config/feature-flags.config.ts` | Shows or hides the danger zone in settings. The server flag remains the authority. |
| `EXPO_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION` | `true` | same | Same, for team accounts. |
| `EXPO_PUBLIC_AUTH_PASSWORD` | `true` | `features/auth/config/auth.config.ts` | Email + password sign-in. |
| `EXPO_PUBLIC_AUTH_MAGIC_LINK` | `false` | same | Magic-link sign-in. |
| `EXPO_PUBLIC_AUTH_OTP` | `false` | same | Email OTP sign-in. |
| `EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS` | `google` | same **and** `apps/native/app.config.ts` | Comma-separated provider list. Adding `apple` also flips a build-time entitlement — see the warning below. |
| `EXPO_PUBLIC_AUTH_IDENTITY_LINKING` | `false` | same | Identity linking in account settings. |
| `EXPO_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX` | `false` | same | Adds the terms/privacy checkbox to sign-up. Its two links open against `EXPO_PUBLIC_SITE_URL` in a device browser. |
| `EXPO_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS` | `false` | `features/auth/config/password-policy.config.ts` | Must stay in lockstep with web's `NEXT_PUBLIC_` twin. |
| `EXPO_PUBLIC_PASSWORD_REQUIRE_NUMBERS` | `false` | same | Same. |
| `EXPO_PUBLIC_PASSWORD_REQUIRE_UPPERCASE` | `false` | same | Same. |

Three team-account flags are read by `features/core/config/feature-flags.config.ts` but are **not** present in the committed file, so they fall back to their code defaults: `EXPO_PUBLIC_ENABLE_TEAM_ACCOUNTS` (default `true`), `EXPO_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION` (default `true`), `EXPO_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY` (default `false`). Add them explicitly if you want to change the shape of the workspace model — `docs/native/workspaces.mdoc` covers what each one does.

{% alert type="warning" title="Adding `apple` to the provider list is a build change, not an env change" %}
`apps/native/app.config.ts` parses `EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS` at prebuild time and strips the `com.apple.developer.applesignin` entitlement whenever the list lacks `apple`. Adding it re-injects an entitlement that makes **even simulator builds require a paid Apple Developer signing team**. Full trigger table: `docs/native/config-requirements.mdoc`. If the build already fails with [no code signing certificates](/docs/next-supabase-turbo/native/troubleshooting#ios-build-fails-with-no-code-signing-certificates), removing the flag is not enough on its own — a clean prebuild is required.
{% /alert %}

Mirrored values (password policy, feature flags, auth flags) drift silently — native will happily accept a password web rejects. `docs/native/config-requirements.mdoc` owns the cross-config trigger table; treat it as the checklist whenever you change a `NEXT_PUBLIC_*` value on web.

## Device classes: which host can reach what

This is the part that costs people an afternoon. `127.0.0.1` means something different on every device class, and the committed defaults are not one-size-fits-all — the kit compensates in code for one of the three. If requests are already failing on one platform but not another, [the app cannot reach Supabase or the API](/docs/next-supabase-turbo/native/troubleshooting#the-app-cannot-reach-supabase-or-the-api) is the checklist for it.

| Target | Can it reach the Mac's loopback? | `EXPO_PUBLIC_SUPABASE_URL` | `EXPO_PUBLIC_API_BASE_URL` / `EXPO_PUBLIC_SITE_URL` | Override needed? |
| --- | --- | --- | --- | --- |
| **iOS simulator** | Yes — it shares the host network stack | committed `http://127.0.0.1:54321` | committed `http://localhost:3000` | **None.** |
| **Android emulator** | No — its own `127.0.0.1` is the emulator | committed value, rewritten at runtime to `http://10.0.2.2:54321` | committed value, rewritten to `http://10.0.2.2:3000` | **None** — the rewrite is automatic. |
| **Physical device** | No — different machine on the LAN | `http://<mac-LAN-IP>:54321` | `http://<mac-LAN-IP>:3000` | **Yes**, in `.env.development.local`. |

### The Android rewrite, and why it lives in code

`10.0.2.2` is Android's fixed alias for the host machine's localhost. The obvious fix is to keep a personal `.env.development.local` with Android values in it — which then has to be edited back every time you switch to the iOS simulator, and gets stale silently.

Instead, `apps/native/features/core/dev-host.ts` performs the substitution at the point of use:

```ts
export function resolveDevHost(url: string): string {
  if (!__DEV__ || Platform.OS !== 'android') return url;

  return url.replace(
    /^(https?:\/\/)(127\.0\.0\.1|localhost)(?=[:/]|$)/i,
    '$110.0.2.2',
  );
}
```

Three properties make this safe:

- The `__DEV__` guard means the rewrite can never fire in a production build.
- The anchored regex only matches a loopback **host**. LAN IPs, hostnames and remote URLs pass through untouched, so physical-device testing and staging/production URLs are unaffected.
- It is applied at the two network boundaries — `features/core/supabase.ts` wraps the client URL with it, and `features/core/api-client.ts` wraps `EXPO_PUBLIC_API_BASE_URL` on every `fetch`. `features/auth/components/terms-and-conditions-field.tsx` applies it too, because those links open in a browser **on the device**.

The result: one committed `.env.development` works on both the iOS simulator and the Android emulator with no override at all.

**Opting out.** If you use `adb reverse tcp:N tcp:N` to forward host ports onto the emulator's own loopback, the rewrite gets in the way. Override the URL in `.env.development.local` with a non-loopback value (your LAN IP) to bypass it — the regex will not match.

### Images, and why the rewrite needs an inverse

The Supabase client is not the only thing that hits the network. A React Native `<Image source={{ uri }}>` fetches directly, bypassing the client entirely — so an avatar URL stored as `http://127.0.0.1:54321/...` renders as a broken image on the Android emulator. The same module handles it at the render boundary:

```ts
export function resolveImageUri(
  uri: string | null | undefined,
): string | undefined {
  if (!uri) return undefined;

  return resolveDevHost(uri);
}
```

It returns `undefined` rather than an empty string so callers keep their `uri ? <Image /> : null` guard. It is applied in `features/makerkit/components/workspace-avatar.tsx`, `app/(home)/user-menu.tsx` and `app/(home)/(user)/(tabs)/settings/index.tsx`.

The inverse matters more. `supabase.storage.getPublicUrl()` builds its URL from the *client's* host — which on Android is already `10.0.2.2`. Uploading an avatar from the Android emulator would therefore persist `http://10.0.2.2:54321/...` into your local database, and that row is shared: the web app and the iOS simulator would both render a broken image from then on. `features/makerkit/components/picture-controls.tsx` reverses the rewrite before persisting:

```ts
export function canonicalizeStorageHost(url: string): string {
  if (!__DEV__ || Platform.OS !== 'android') return url;

  return url.replace(/^(https?:\/\/)10\.0\.2\.2(?=[:/]|$)/i, '$1127.0.0.1');
}
```

Rewrite on the way out, canonicalize on the way in. If you add a screen that either renders a storage URL or writes one back, apply the matching helper. Both failure modes — and how to repair a row already stored with the wrong host — are in [images load on one platform only](/docs/next-supabase-turbo/native/troubleshooting#images-load-on-one-platform-only).

### Physical device

A phone on your Wi-Fi cannot resolve `localhost` to your Mac. Find the Mac's LAN address and pin it:

```bash
ipconfig getifaddr en0     # Wi-Fi; try en1 if that returns nothing
```

```bash
# apps/native/.env.development.local  (gitignored)
EXPO_PUBLIC_SUPABASE_URL=http://192.168.1.42:54321
EXPO_PUBLIC_API_BASE_URL=http://192.168.1.42:3000
EXPO_PUBLIC_SITE_URL=http://192.168.1.42:3000
```

Then rebuild onto the device (`pnpm ios:native` with the device selected, or `pnpm android:native` with USB debugging on). The device and the Mac must be on the same network, and a macOS firewall prompt for the Node process has to be allowed. Because a LAN IP is not a loopback host, `resolveDevHost` leaves these values alone on every platform.

## Development builds, not Expo Go

The kit is not runnable in Expo Go, and that is a design decision rather than an oversight.

`expo-dev-client` is a dependency, so `expo start` targets a development build by default. More fundamentally, `apps/native/app.json` declares four config plugins — `expo-router`, `expo-secure-store`, `expo-localization`, and `expo-image-picker` with a custom `photosPermission` string — and `apps/native/app.config.ts` conditionally strips an iOS entitlement. Config plugins write into the generated native project at prebuild time: `Info.plist` permission strings, entitlements, `AndroidManifest.xml` entries. A pre-compiled, general-purpose Expo Go binary cannot carry project-specific native changes, so the kit's session storage (`expo-secure-store`), avatar picker and Apple entitlement handling have nowhere to live in it.

The practical consequence is a two-tier reload model.

| You changed… | What's required | Why |
| --- | --- | --- |
| Any `.ts` / `.tsx` / `.css` file | Nothing — Fast Refresh | Metro re-transforms and pushes the module. |
| An `apps/native/.env.*` file | Restart Metro (`pnpm start:native`, or `start:clear` if the value persists) | `EXPO_PUBLIC_*` values are inlined at Babel transform time, not read at runtime. |
| `metro.config.js`, or added/removed a JS-only dependency | `pnpm start:native:clear` | Metro's resolution and transform caches are keyed on the previous state — see [Metro serves something stale](/docs/next-supabase-turbo/native/troubleshooting#metro-serves-something-stale). |
| `app.json`, `app.config.ts`, or the OAuth provider list | `pnpm prebuild:native` then `pnpm ios:native` / `pnpm android:native` | These are inputs to native project generation, not to the JS bundle. |
| Added a dependency containing native code or a config plugin | Same — prebuild and rebuild | The module has to be linked and compiled into the binary. Skipping this looks like [a new native module does not work](/docs/next-supabase-turbo/native/troubleshooting#a-new-native-module-does-not-work). |
| Bumped the Expo SDK | Same, plus read the SDK-bump note in `docs/native/config-requirements.mdoc` | Catalog and `overrides:` must move in lockstep. |

`ios/` and `android/` are gitignored: the kit uses continuous native generation, so those directories are build output. Never hand-edit them — `expo prebuild --clean` deletes them. Express native changes as config-plugin input instead. `docs/native/building-shipping.mdoc` covers prebuild and EAS in depth.

## First-run notes

Two files appear on your first run and should not surprise you.

- **`apps/native/lib/i18n/messages.generated.ts`** is generated and **committed**. `start`, `start:clear`, `ios` and `android` all run the generator before Expo, so it is refreshed automatically; `typecheck` runs it in check mode and fails if it is stale. Never hand-edit it — native inherits web's catalogue and keeps only a local delta. See `docs/native/i18n.mdoc`, and [i18n generator and runtime errors](/docs/next-supabase-turbo/native/troubleshooting#i18n-generator-and-runtime-errors) for what each generator message means.
- **`apps/native/uniwind-types.d.ts`** is generated by Metro on first run, from the `dtsFile` option in `metro.config.js`. It is gitignored and referenced from `tsconfig.json`'s `include`, so a typecheck run before the app has ever been started can report class-name type errors. Start the app once, then typecheck.

Useful local URLs while you work, all from `apps/web/supabase/config.toml`:

| Service | URL | Use |
| --- | --- | --- |
| Web app / `/api/v1` | `http://localhost:3000` | The API the native app calls. |
| Supabase API | `http://127.0.0.1:54321` | What `EXPO_PUBLIC_SUPABASE_URL` points at. |
| Supabase Studio | `http://localhost:54323` | Inspect rows the app wrote. |
| Mail catcher | `http://localhost:54324` | Confirmation, OTP and invitation emails sent from native flows. |

## When it doesn't work

Every failure below has a specific fix, and clearing caches at random is not one of them. [Troubleshooting the Expo app](/docs/next-supabase-turbo/native/troubleshooting) is organised by symptom; these are the ones you are most likely to hit while getting the app running for the first time.

| Symptom | Fix |
| --- | --- |
| `xcodebuild` exits 70, `Unable to find a destination matching …` | [The simulator exists but Xcode will not build to it](/docs/next-supabase-turbo/native/troubleshooting#the-simulator-exists-but-xcode-will-not-build-to-it) |
| `No code signing certificates` on an iOS build | [iOS build fails signing](/docs/next-supabase-turbo/native/troubleshooting#ios-build-fails-with-no-code-signing-certificates) |
| `Unable to resolve module …` for a package that is installed | [Metro serves something stale](/docs/next-supabase-turbo/native/troubleshooting#metro-serves-something-stale) |
| `pnpm typecheck` errors that don't match the source | [Typecheck disagrees with the code](/docs/next-supabase-turbo/native/troubleshooting#typecheck-disagrees-with-the-code) |
| `expo-doctor` reports duplicate `react` / `react-dom` | [A known false positive](/docs/next-supabase-turbo/native/troubleshooting#expo-doctor-flags-react-react-dom-as-duplicates) |
| Requests fail on the Android emulator only | [The app cannot reach Supabase or the API](/docs/next-supabase-turbo/native/troubleshooting#the-app-cannot-reach-supabase-or-the-api) |
| The app crashes at launch with `Missing EXPO_PUBLIC_…` | [Environment variable errors](/docs/next-supabase-turbo/native/troubleshooting#environment-variable-errors) |
