Troubleshooting the Expo app | Expo Supabase SaaS Kit
Diagnose and fix the failures you will actually hit in apps/native — stale Metro caches, expo-doctor false positives, Android networking, iOS signing, env-var crashes and i18n generator errors.
This page is organised by the symptom you see, not by subsystem. Each entry states what appears on screen or in the terminal, why the kit produces it, and what to do. Everything here is specific to apps/native in this repository — where a fix belongs to another page, it links there rather than restating it.
If you have not yet got the app running once, start with docs/native/running-locally.mdoc; several symptoms below are just a missing prerequisite.
Triage table
| What you see | Go to |
|---|---|
Unable to resolve module …, or code that changed on disk does not change in the app | Metro serves something stale |
pnpm typecheck errors that do not match the code; unknown route names after a rename | Typecheck disagrees with the code |
expo-doctor reports duplicate react / react-dom | expo-doctor duplicate dependency warning |
Invalid hook call | Two copies of React in the bundle |
| Requests fail on the Android emulator but work on the iOS simulator | The app cannot reach Supabase or the API |
| Avatars render on iOS but not Android, or a broken avatar on web after an Android upload | Images load on one platform only |
The app crashes at launch with Missing EXPO_PUBLIC_… | Environment variable errors |
No code signing certificates on expo run:ios | iOS build fails signing |
| Auth emails never arrive; GoTrue 500 after a template edit; native auth breaks after enabling captcha | Auth and email failures |
i18n:native — N problem(s) found, or ⟦some.key⟧ on screen | i18n generator and runtime errors |
| A newly installed native module is not found at runtime | A new native module does not work |
Every catalog:expo reference turned into a literal version | The catalog pinning was rewritten |
useAuth must be used inside <AuthProvider> and similar | Provider-boundary errors |
| The app drops you back to sign-in on its own | The app signs you out unexpectedly |
Metro serves something stale
Symptom. Unable to resolve module <name> from … for a package that is plainly in node_modules. Or an edit you made does not appear, or an old version of a module is bundled after you switched branches.
Cause. Metro caches both transforms and module resolution. A dependency add or remove, an edit to apps/native/metro.config.js, or a branch switch that moves dependencies leaves that cache describing a tree that no longer exists.
Fix.
pnpm start:native:clearThat maps to expo start --clear in apps/native/package.json (with the i18n generator run first, as every start script does).
The rule of thumb: clear the cache when the error disagrees with what is on disk. If the module really is missing from node_modules, clearing achieves nothing — run pnpm install instead. Neither cache command belongs in a normal edit-run loop.
Typecheck disagrees with the code
Symptom. pnpm typecheck reports errors that do not correspond to anything in the source, or expo-router route names are stale after you renamed or moved a file under apps/native/app/.
Cause. Two generated artefacts feed the typecheck. apps/native/tsconfig.json includes .expo/types/**/*.ts — the expo-router route types Metro writes — and sets tsBuildInfoFile to node_modules/.cache/tsbuildinfo.json for incremental builds. Either can outlive the change that invalidated it.
Fix.
pnpm clean:native:cacheIt runs git clean -xdf .turbo .expo node_modules/.cache inside apps/native, so it drops the Turbo cache, the generated .expo types and the tsc build info — and keeps node_modules. Reinstalling dependencies is not part of this fix. If you do want a full wipe, pnpm --filter native-app clean removes node_modules too; there is no root-level alias for that on purpose.
Route types come back the next time Metro runs, so start the dev server once after cleaning if you rely on typed routes.
apps/native's typecheck script is node ../../tooling/scripts/src/i18n-native.mjs && tsc --noEmit. A failure that mentions messages.generated.ts is the catalogue check, not TypeScript — see i18n generator and runtime errors.
expo-doctor flags react / react-dom as duplicates
Symptom. pnpm doctor:native fails the check "Check that no duplicate dependencies are installed", naming react and react-dom.
This one is an expected false positive. Native and web deliberately run different React versions:
| App | React version | Pinned where |
|---|---|---|
apps/native | 19.2.3 | catalogs.expo in pnpm-workspace.yaml — the exact version react-native@0.86.0's bundled renderer was compiled against |
apps/web | 19.2.8 | the root catalog: in pnpm-workspace.yaml |
Verify the numbers rather than trusting any document, including this one:
node -p "require('./apps/native/node_modules/react/package.json').version"node -p "require('./apps/web/node_modules/react/package.json').version"The two live in separate pnpm-isolated trees. Native's bundle only ever resolves its own copy, and apps/native/metro.config.js additionally forces react and react-dom to resolve from apps/native no matter which package imports them. Doctor's scanner walks node_modules/.pnpm/ and counts instances across the whole store without modelling that isolation, so the count is real but the conclusion is not.
If a native module — anything with an iOS/Android side, such as react-native-svg or an expo-* package — appears in that duplicate list, it is genuine and will break the build or produce two instances of a native singleton. Investigate with pnpm why <pkg> and resolve it in pnpm-workspace.yaml.
Invalid hook call
Symptom. Invalid hook call. Hooks can only be called inside of the body of a function component. on a screen that looks correct.
Cause. Two copies of React in the bundle. Metro resolves bare imports by walking up from the importing file, so importing a React-bearing @kit/* subpath can pull a second React in from that package's own tree.
Fix. apps/native/metro.config.js already guards against this by re-anchoring react and react-dom resolution to apps/native/package.json. If you hit the error, check that the SINGLETONS block in that file is intact, then clear the Metro cache (pnpm start:native:clear) so the old resolution map is discarded. If a @kit/* package started declaring its own react dependency, that is the thing to fix.
The app cannot reach Supabase or the API
Symptom. Sign-in hangs or fails, and /api/v1 queries error, on the Android emulator — while the iOS simulator is fine. Or the reverse on a physical device.
What the kit already does for you. apps/native/features/core/dev-host.ts exports resolveDevHost(url), which rewrites http(s)://127.0.0.1 and http(s)://localhost to 10.0.2.2 — Android's fixed alias for the host machine's loopback — but only when __DEV__ && Platform.OS === 'android':
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', );}It is wired into both features/core/supabase.ts and features/core/api-client.ts, so the committed apps/native/.env.development works on both platforms with no override. LAN IPs and remote URLs pass through untouched, and the __DEV__ guard means it can never fire in a production build.
So the usual cause is something else. Work down this list:
| Check | How |
|---|---|
| Is the Supabase stack running? | pnpm run supabase:web:start — native points at http://127.0.0.1:54321 |
| Is the Next.js app running? | pnpm dev — /api/v1 is served by apps/web, not by Metro |
| Physical device? | Loopback means the phone itself. Set EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_API_BASE_URL to your machine's LAN IP in apps/native/.env.development.local |
| Did you override the URL locally? | A non-loopback value in .env.development.local defeats the rewrite by design — that is the documented opt-out for adb reverse workflows. Remove the override to get the automatic behaviour back |
| Did you just edit an env file? | EXPO_PUBLIC_* values are inlined at build time. Restart the dev server; clear the cache if the old value survives |
Full device and environment setup is in docs/native/running-locally.mdoc; the API client itself is covered in docs/native/data-fetching.mdoc.
Images load on one platform only
Avatars are blank on Android, fine on iOS
Cause. React Native's <Image> fetches the URL directly — it never goes through the Supabase client, so it never gets the loopback rewrite that features/core/supabase.ts applies.
Fix. Apply resolveImageUri at the <Image source={{ uri }}> boundary. It is the null-safe variant of resolveDevHost and returns undefined for empty input, so a uri ? … : null guard keeps working:
import { resolveImageUri } from '~/features/core/dev-host';<AvatarImage source={{ uri: resolveImageUri(pictureUrl) }} />;Existing call sites you can copy from: apps/native/features/makerkit/components/workspace-avatar.tsx, apps/native/app/(home)/user-menu.tsx, and apps/native/app/(home)/(user)/(tabs)/settings/index.tsx.
An avatar uploaded from Android is broken on web and the iOS simulator
Cause. The inverse hazard. getPublicUrl() builds its URL from the client's host, which on Android in dev is the rewritten one — so an upload would persist http://10.0.2.2:54321/… into the shared local database, where the web app and the iOS simulator cannot resolve it.
Fix. canonicalizeStorageHost(url) reverses the rewrite before the URL is persisted, and apps/native/features/makerkit/components/picture-controls.tsx already calls it around getPublicUrl. If you add a new upload path, run the public URL through it before writing the value anywhere. To repair a row already stored with 10.0.2.2, re-upload the image from a client on the canonical host, or correct the stored URL directly.
Environment variable errors
Three errors come straight from source. The message tells you which file to edit.
| Error text | Thrown by | When | Fix |
|---|---|---|---|
Missing EXPO_PUBLIC_SUPABASE_URL or EXPO_PUBLIC_SUPABASE_PUBLIC_KEY. Set them in apps/native/.env.development before running expo start. | apps/native/features/core/supabase.ts | At module scope — the app fails to boot | Set both in apps/native/.env.development (or .env.development.local) and restart the dev server |
Missing EXPO_PUBLIC_API_BASE_URL. Set it in apps/native/.env.development. | apps/native/features/core/api-client.ts | Inside api() — surfaces as a failed query, not a crash | Set EXPO_PUBLIC_API_BASE_URL to the URL of the running apps/web |
EXPO_PUBLIC_API_BASE_URL must use https:// in a production build. | apps/native/features/core/api-client.ts | Only when !__DEV__ | Point release builds at an HTTPS origin. The guard exists so a bearer token is never sent over cleartext in a shipped binary |
The first fires at import time because a Supabase client with no URL cannot be constructed at all; the second and third fire per request, so a misconfigured API base URL leaves the app running with every server query failing.
Because EXPO_PUBLIC_* values are inlined into the JS bundle, changing one in a release build requires a new build — not a restart, and not an over-the-air toggle. docs/native/config-requirements.mdoc lists every flag and what changing it forces elsewhere.
iOS build fails with "No code signing certificates"
Symptom. expo run:ios (or pnpm ios:native) fails signing with "No code signing certificates", even for a simulator build, after you added apple to EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS.
Cause. expo-apple-authentication is a permanent dependency of apps/native (the Sign in with Apple code imports it), and Expo autolinks its config plugin at prebuild — so the com.apple.developer.applesignin entitlement is injected by the package merely being installed, not by anything in the plugins array. That entitlement makes even simulator builds require a paid Apple Developer signing team.
apps/native/app.config.ts compensates: unless EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS contains apple, it strips the entitlement back out.
if (appleEnabled) return merged;// Apple off → remove the entitlement Expo autolinking injectsreturn withEntitlementsPlist(merged, (cfg) => { delete cfg.modResults['com.apple.developer.applesignin']; return cfg;});Fix, if you do not have a paid Apple account. Remove apple from EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS, then re-prebuild cleanly:
rm -rf apps/native/iospnpm prebuild:nativeThe clean step matters: a merge prebuild keeps the stale entitlement in the existing ios/ project, so the build keeps failing with the flag already off.
The iOS app is fully usable without Apple sign-in (password plus Google). Apple is an App Store submission gate under Guideline 4.8, not a functional requirement — see docs/native/config-requirements.mdoc for the full Apple checklist, and docs/native/building-shipping.mdoc for prebuild and EAS.
Auth and email failures
Auth emails never arrive on a hosted Supabase project
Cause. Supabase's built-in email provider caps auth emails at a handful per hour project-wide, and the cap is raisable only by configuring custom SMTP. The native flows (sign-up confirmation, password reset) cannot be exercised under it.
Fix. Configure custom SMTP in the Supabase dashboard before pointing a native email flow at a hosted project. docs/native/config-requirements.mdoc marks this as a blocker and links to the provider and DNS work it implies.
GoTrue returns 500 "… ends in a non-text context" after editing a template locally
Cause. Not a syntax error, despite how it reads. The Supabase CLI bind-mounts the email templates into kong, which can serve a size-changed file truncated to its previous length. GoTrue then fails to parse the truncated body.
Fix. Restart kong and auth, then confirm the served byte count matches the file on disk:
KONG=supabase_kong_next-supabase-saas-kit-turbo; AUTH=supabase_auth_next-supabase-saas-kit-turbodocker restart $KONG $AUTHdocker exec $AUTH sh -c "wget -qO- http://$KONG:8088/email/confirmation.html | wc -c"wc -c apps/web/supabase/templates/confirm-email.htmlIf the two numbers differ, kong is still serving the stale mount.
Native auth breaks entirely after enabling captcha in Supabase
Cause. GoTrue's captcha setting is a global toggle: it gates sign-in, OTP and password reset for every client of the project. The native app sends no Turnstile token — there is no native Turnstile SDK in use — so every one of those calls is rejected.
Fix. Turn the captcha toggle off. Rate limiting is the substitute guard; [auth.rate_limit] email_sent still carries upstream's permissive default, so tighten it before exposing native sign-up publicly, remembering that the same ceiling throttles web's invitations and password resets. docs/native/config-requirements.mdoc records this as an anti-trigger and gives the numbers.
i18n generator and runtime errors
pnpm i18n:native (or pnpm typecheck) fails with N problems
The generator refuses to write anything while any check fails, so a stale catalogue is never emitted. Each message names the offending key. In one line each:
| Message starts with | What it means |
|---|---|
orphan override | Web no longer has the key you override — upstream renamed or removed it |
redundant override | Web's value now equals yours; the divergence is over |
shadowed addition | Upstream now ships a key you invented |
shape drift / shape conflict | Web turned a string into a group of keys, or the reverse, and your delta still targets the old shape |
ICU drift | Web's placeholder set no longer matches your override's |
malformed ICU | Unbalanced braces that would throw at runtime |
new upstream namespace | Web added a message file the generator does not know about |
missing translation / extra key | A locale does not resolve to the same key set as the default locale |
… is never read | A delta directory exists for a locale that is not built |
messages.generated.ts is out of date | Web strings or the local delta changed without a regenerate — run pnpm i18n:native and commit the result |
What to do about each, the override file format, and the add-a-locale checklist are in docs/native/i18n.mdoc. Never hand-edit apps/native/lib/i18n/messages.generated.ts; the check compares it byte-for-byte against a fresh render.
A screen shows ⟦some.key⟧
Cause. A missing message key. apps/native/lib/i18n/provider.tsx renders ⟦${key}⟧ as the dev-only fallback and logs [i18n] <message> to the console; in a release build the bare key is rendered instead.
Fix. Add the key to apps/native/lib/i18n/additions/<locale>/ (if web does not have it) and regenerate. If the key is built at runtime, route it through tDynamic from apps/native/lib/i18n/dynamic.ts, which forces an explicit fallback.
Plural messages crash or render wrong for a locale you just added
Cause. Hermes ships without Intl.PluralRules. apps/native/app/_layout.tsx imports the polyfill and then one locale-data file per locale:
import '@formatjs/intl-pluralrules/polyfill-force.js';import '@formatjs/intl-pluralrules/locale-data/en.js';Nothing enforces the second import. Adding a locale to LOCALES in tooling/scripts/src/i18n-native.mjs without adding its locale-data/<locale>.js import passes every generator check and every typecheck, and fails only at runtime, only on plural messages.
Fix. Add the matching import. Keep the .js extension — the package's exports map declares ./locale-data/*, so an extensionless specifier misses and Metro falls back to legacy resolution with a warning.
A new native module does not work
Symptom. You installed a package, reloaded Metro, and get a "native module not found" style failure or a no-op.
Cause. A package with native code ships a config plugin that writes into the iOS and Android projects. Metro only rebuilds JavaScript — the native side of the app on your simulator has no idea the module exists.
Fix.
- Add the dependency, pinned via the
expocatalog inpnpm-workspace.yaml(see below). - Add it to
pluginsif the package requires explicit registration (many are autolinked). pnpm prebuild:native— this runsexpo prebuild --clean.- Rebuild and reinstall the app:
pnpm ios:nativeorpnpm android:native.
Expo Go cannot load a custom native module; you need the dev client build. docs/native/building-shipping.mdoc covers dev builds.
The catalog pinning was rewritten
Symptom. After running expo install --fix, every catalog:expo reference in apps/native/package.json (and in packages/mobile-ui's peerDependencies) has become a literal version string.
Cause. --fix writes concrete versions. Every native dependency in this repo is pinned centrally under catalogs.expo in pnpm-workspace.yaml, so --fix destroys the convention that makes an SDK bump a single-file change. This is why pnpm deps:native:check exists (expo install --check) and has no --fix counterpart.
Fix. Revert the change (git checkout the affected package.json files), then hand-edit catalogs.expo from the SDK's bundledNativeModules.json and verify:
pnpm deps:native:checkpnpm-workspace.yaml also carries an overrides: block (currently expo-constants and react-native-svg) that forces a single resolution where transitive dependencies pin an older patch. pnpm overrides win over catalog entries, so a stale entry there silently holds one package on the previous SDK while everything else moves. pnpm install accepts it without comment; only expo-doctor notices. Bump both blocks in lockstep.
Note also the expo.install.exclude block in apps/native/package.json: typescript is excluded from Expo's version check because the monorepo runs a newer TypeScript than the SDK expects. That exclusion is deliberate, not drift.
Provider-boundary errors
Symptom. One of:
useAuth must be used inside <AuthProvider>useActiveWorkspace must be used inside <WorkspaceProvider>(alsouseSetActiveWorkspace,useWorkspaceSelection)useThemePreference must be used inside <ThemePreferenceProvider>
Cause. The hook ran outside the part of the tree that provides its context. The auth, theme, React Query and i18n providers are mounted in apps/native/app/_layout.tsx; WorkspaceProvider is mounted lower down, in apps/native/app/(home)/_layout.tsx.
Fix. Workspace-aware hooks only work on screens inside the (home) group. If you need one on an (auth) screen, you are reaching for state that does not exist yet at that point in the flow — the routing groups and their guards are explained in docs/native/routing-navigation.mdoc.
The app signs you out unexpectedly
Symptom. The app returns to the sign-in screen on its own, or bounces to the MFA challenge, shortly after a screen loads.
Cause. This is deliberate. apps/native/lib/react-query-provider.tsx installs a global QueryCache / MutationCache error handler: any ApiError with code unauthenticated triggers supabase.auth.signOut(), and mfa-required redirects to the MFA challenge route. Those two codes are also excluded from retries.
Fix. The redirect is the correct response to those codes — look at why the API returned them. The common local causes are an expired session, or apps/native and apps/web pointing at different Supabase projects, so the JWT the app holds is not valid for the API it is calling. Compare EXPO_PUBLIC_SUPABASE_URL in apps/native/.env.development with apps/web's Supabase URL. Authentication state is covered in docs/native/authentication.mdoc.
Still stuck
Two diagnostics are worth running before anything else:
pnpm doctor:native # expo-doctor — walks the full native dependency graphpnpm deps:native:check # expo install --check — dependency versions vs the installed SDKRead doctor:native output with the duplicate-React caveat in mind; everything else it reports is worth acting on.
Where to look next, in order:
| Question | Look at |
|---|---|
| Is this feature even built? | docs/native/covered-features.mdoc — the authority on status |
| Is something misconfigured, and what does changing it force elsewhere? | docs/native/config-requirements.mdoc |
| Is my environment or device set up correctly? | docs/native/running-locally.mdoc |
| Which layer owns this code? | docs/native/project-structure.mdoc |
| What does this API endpoint return? | apps/web/app/api/v1/AGENTS.md, plus docs/native/data-fetching.mdoc |
The source files that explain most native-only behaviour are small and heavily commented — apps/native/features/core/dev-host.ts, apps/native/features/core/api-client.ts, apps/native/features/core/supabase.ts, apps/native/app.config.ts and apps/native/metro.config.js. Reading the comment above the function you are debugging is usually faster than reasoning about the symptom.