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 (with a simulator runtime) 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. |
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. |
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 docs/native/troubleshooting.mdoc 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.
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).
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.
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.
| 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.
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. |
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. |
| 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.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
Metro serving a stale module, expo-doctor's known false positive on duplicate React copies, typecheck errors that contradict the source, and iOS signing failures each have a specific fix. They are collected in docs/native/troubleshooting.mdoc — start there rather than clearing caches at random.