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 seeGo to
Unable to resolve module …, or code that changed on disk does not change in the appMetro serves something stale
pnpm typecheck errors that do not match the code; unknown route names after a renameTypecheck disagrees with the code
expo-doctor reports duplicate react / react-domexpo-doctor duplicate dependency warning
Invalid hook callTwo copies of React in the bundle
Requests fail on the Android emulator but work on the iOS simulatorThe app cannot reach Supabase or the API
Avatars render on iOS but not Android, or a broken avatar on web after an Android uploadImages load on one platform only
The app crashes at launch with Missing EXPO_PUBLIC_…Environment variable errors
No code signing certificates on expo run:iosiOS build fails signing
Auth emails never arrive; GoTrue 500 after a template edit; native auth breaks after enabling captchaAuth and email failures
i18n:native — N problem(s) found, or ⟦some.key⟧ on screeni18n generator and runtime errors
A newly installed native module is not found at runtimeA new native module does not work
Every catalog:expo reference turned into a literal versionThe catalog pinning was rewritten
useAuth must be used inside <AuthProvider> and similarProvider-boundary errors
The app drops you back to sign-in on its ownThe 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:clear

That 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:cache

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

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:

AppReact versionPinned where
apps/native19.2.3catalogs.expo in pnpm-workspace.yaml — the exact version react-native@0.86.0's bundled renderer was compiled against
apps/web19.2.8the 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.

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:

CheckHow
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 textThrown byWhenFix
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.tsAt module scope — the app fails to bootSet 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.tsInside api() — surfaces as a failed query, not a crashSet 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.tsOnly 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 injects
return 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/ios
pnpm prebuild:native

The 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-turbo
docker restart $KONG $AUTH
docker exec $AUTH sh -c "wget -qO- http://$KONG:8088/email/confirmation.html | wc -c"
wc -c apps/web/supabase/templates/confirm-email.html

If 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 withWhat it means
orphan overrideWeb no longer has the key you override — upstream renamed or removed it
redundant overrideWeb's value now equals yours; the divergence is over
shadowed additionUpstream now ships a key you invented
shape drift / shape conflictWeb turned a string into a group of keys, or the reverse, and your delta still targets the old shape
ICU driftWeb's placeholder set no longer matches your override's
malformed ICUUnbalanced braces that would throw at runtime
new upstream namespaceWeb added a message file the generator does not know about
missing translation / extra keyA locale does not resolve to the same key set as the default locale
… is never readA delta directory exists for a locale that is not built
messages.generated.ts is out of dateWeb 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.

  1. Add the dependency, pinned via the expo catalog in pnpm-workspace.yaml (see below).
  2. Add it to plugins if the package requires explicit registration (many are autolinked).
  3. pnpm prebuild:native — this runs expo prebuild --clean.
  4. Rebuild and reinstall the app: pnpm ios:native or pnpm 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:check

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> (also useSetActiveWorkspace, 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 graph
pnpm deps:native:check # expo install --check — dependency versions vs the installed SDK

Read doctor:native output with the duplicate-React caveat in mind; everything else it reports is worth acting on.

Where to look next, in order:

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