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.
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 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.
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.
The short path
From the repository root, in three terminals:
# 1 — backendpnpm 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 buildpnpm ios:native # or: pnpm android:nativeThe 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 ("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 or signing. |
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 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, 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.
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 covers the recovery if you already ran it.
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 → .envThe 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 ofapps/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.
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 for the three messages and which file each one names.
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.
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, removing the flag is not enough on its own — a clean prebuild is required.
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 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:
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.tswraps the client URL with it, andfeatures/core/api-client.tswrapsEXPO_PUBLIC_API_BASE_URLon everyfetch.features/auth/components/terms-and-conditions-field.tsxapplies 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:
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:
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.
Physical device
A phone on your Wi-Fi cannot resolve localhost to your Mac. Find the Mac's LAN address and pin it:
ipconfig getifaddr en0 # Wi-Fi; try en1 if that returns nothing# apps/native/.env.development.local (gitignored)EXPO_PUBLIC_SUPABASE_URL=http://192.168.1.42:54321EXPO_PUBLIC_API_BASE_URL=http://192.168.1.42:3000EXPO_PUBLIC_SITE_URL=http://192.168.1.42:3000Then 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. |
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. |
| 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.tsis generated and committed.start,start:clear,iosandandroidall run the generator before Expo, so it is refreshed automatically;typecheckruns 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. Seedocs/native/i18n.mdoc, and i18n generator and runtime errors for what each generator message means.apps/native/uniwind-types.d.tsis generated by Metro on first run, from thedtsFileoption inmetro.config.js. It is gitignored and referenced fromtsconfig.json'sinclude, 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 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 |
No code signing certificates on an iOS build | iOS build fails signing |
Unable to resolve module … for a package that is installed | Metro serves something stale |
pnpm typecheck errors that don't match the source | Typecheck disagrees with the code |
expo-doctor reports duplicate react / react-dom | A known false positive |
| Requests fail on the Android emulator only | The app cannot reach Supabase or the API |
The app crashes at launch with Missing EXPO_PUBLIC_… | Environment variable errors |