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 (with a simulator runtime) 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.

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.
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 docs/native/troubleshooting.mdoc 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.

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.

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

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.