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.

RequirementHow to satisfy itWhy
Local Supabase is runningpnpm run supabase:web:startThe app authenticates and reads directly against http://127.0.0.1:54321.
The Next.js app is runningpnpm 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 installedXcode plus its iOS platform (xcodebuild -downloadPlatform iOS) for iOS, Android Studio (with an emulator image and ANDROID_HOME set) for AndroidThe 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 short path

From the repository root, in three terminals:

# 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 aliasIn apps/nativeRunsReach for it when
pnpm start:nativepnpm startpnpm i18n && expo startDaily loop. Metro only — the build is already installed.
pnpm start:native:clearpnpm start:clearpnpm i18n && expo start --clearMetro contradicts what's on disk ("unable to resolve" a package that is installed) after a dependency change or branch switch.
pnpm ios:nativepnpm iospnpm i18n && expo run:iosFirst 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:nativepnpm androidpnpm i18n && expo run:androidSame, for the Android emulator or a connected device.
pnpm prebuild:nativepnpm prebuildexpo prebuild --cleanAfter editing app.json / app.config.ts, or adding a package with a config plugin. --clean deletes and regenerates ios/ and android/.
pnpm i18n:nativepnpm i18nregenerates apps/native/lib/i18n/messages.generated.tsRarely by hand — it already runs before start, ios and android.
pnpm i18n:native:checksame script without --writeVerifying the catalogue is in sync without rewriting it. Exits non-zero if stale.
pnpm doctor:nativepnpm doctorexpo-doctorDiagnosing a native dependency graph problem. Read the duplicate-React caveat first — one of its checks reports a known false positive here.
pnpm deps:native:checkpnpm deps:checkexpo install --checkChecking installed versions against what the Expo SDK expects. There is deliberately no --fix alias.
pnpm clean:native:cachepnpm clean:cachegit clean -xdf .turbo .expo node_modules/.cachetypecheck reports errors that don't match the code, or route types are stale after moving a route. Keeps node_modules.
pnpm --filter native-app cleangit clean -xdf .turbo .expo node_modulesNuclear. Requires a reinstall afterwards.
pnpm --filter native-app typechecki18n check + tsc --noEmitA 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.

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.

What each variable does

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

VariableCommitted valueRead byEffect
EXPO_PUBLIC_PRODUCT_NAMEMakerkitfeatures/core/config/app.config.tsProduct 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_URLhttp://localhost:3000features/core/config/app.config.tsThe public web app. Native links out to it (terms and privacy pages), so it must be reachable from the device.
EXPO_PUBLIC_SUPABASE_URLhttp://127.0.0.1:54321features/core/supabase.tsSupabase API root. Missing → the module throws at import time with an explicit message.
EXPO_PUBLIC_SUPABASE_PUBLIC_KEYlocal publishable keyfeatures/core/supabase.tsThe publishable (anon) key. Same key the web app uses locally.
EXPO_PUBLIC_API_BASE_URLhttp://localhost:3000features/core/api-client.tsRoot 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_DELETIONtruefeatures/core/config/feature-flags.config.tsShows or hides the danger zone in settings. The server flag remains the authority.
EXPO_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETIONtruesameSame, for team accounts.
EXPO_PUBLIC_AUTH_PASSWORDtruefeatures/auth/config/auth.config.tsEmail + password sign-in.
EXPO_PUBLIC_AUTH_MAGIC_LINKfalsesameMagic-link sign-in.
EXPO_PUBLIC_AUTH_OTPfalsesameEmail OTP sign-in.
EXPO_PUBLIC_AUTH_OAUTH_PROVIDERSgooglesame and apps/native/app.config.tsComma-separated provider list. Adding apple also flips a build-time entitlement — see the warning below.
EXPO_PUBLIC_AUTH_IDENTITY_LINKINGfalsesameIdentity linking in account settings.
EXPO_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOXfalsesameAdds 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_CHARSfalsefeatures/auth/config/password-policy.config.tsMust stay in lockstep with web's NEXT_PUBLIC_ twin.
EXPO_PUBLIC_PASSWORD_REQUIRE_NUMBERSfalsesameSame.
EXPO_PUBLIC_PASSWORD_REQUIRE_UPPERCASEfalsesameSame.

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.

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.

TargetCan it reach the Mac's loopback?EXPO_PUBLIC_SUPABASE_URLEXPO_PUBLIC_API_BASE_URL / EXPO_PUBLIC_SITE_URLOverride needed?
iOS simulatorYes — it shares the host network stackcommitted http://127.0.0.1:54321committed http://localhost:3000None.
Android emulatorNo — its own 127.0.0.1 is the emulatorcommitted value, rewritten at runtime to http://10.0.2.2:54321committed value, rewritten to http://10.0.2.2:3000None — the rewrite is automatic.
Physical deviceNo — different machine on the LANhttp://<mac-LAN-IP>:54321http://<mac-LAN-IP>:3000Yes, 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.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:

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: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 requiredWhy
Any .ts / .tsx / .css fileNothing — Fast RefreshMetro re-transforms and pushes the module.
An apps/native/.env.* fileRestart 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 dependencypnpm start:native:clearMetro'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 listpnpm prebuild:native then pnpm ios:native / pnpm android:nativeThese are inputs to native project generation, not to the JS bundle.
Added a dependency containing native code or a config pluginSame — prebuild and rebuildThe module has to be linked and compiled into the binary. Skipping this looks like a new native module does not work.
Bumped the Expo SDKSame, plus read the SDK-bump note in docs/native/config-requirements.mdocCatalog 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 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:

ServiceURLUse
Web app / /api/v1http://localhost:3000The API the native app calls.
Supabase APIhttp://127.0.0.1:54321What EXPO_PUBLIC_SUPABASE_URL points at.
Supabase Studiohttp://localhost:54323Inspect rows the app wrote.
Mail catcherhttp://localhost:54324Confirmation, 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.

SymptomFix
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 buildiOS build fails signing
Unable to resolve module … for a package that is installedMetro serves something stale
pnpm typecheck errors that don't match the sourceTypecheck disagrees with the code
expo-doctor reports duplicate react / react-domA known false positive
Requests fail on the Android emulator onlyThe app cannot reach Supabase or the API
The app crashes at launch with Missing EXPO_PUBLIC_…Environment variable errors