Expo React Native App
Purpose
Guide the setup and development of a cross-platform React Native app using Expo, covering icon and font configuration, Metro bundler behaviour, web-platform API access, and UI component gotchas that only surface on specific targets or themes.
When to use
When starting a new Expo project or debugging a problem that appears only on a specific platform (web, dark theme, after a package install). Apply at project setup time to avoid the class of silent failures that only surface on device or in the browser target.
Inputs expected
- Expo SDK version and target platforms (native, web, or both)
- Icon set or UI library in use (e.g.
@expo/vector-icons, custom assets) - External API endpoints the app will call
- Known errors or unexpected rendering behaviour
Guiding principles
- Vector icon fonts must be explicitly preloaded.
@expo/vector-icons(Ionicons and others) renders blank boxes unlessuseFonts(Iconset.font)is called in the root layout and rendering is gated behindfontsLoaded. This is required even when the package is already installed — installation does not trigger font registration. - Clear Metro cache after every package install. After
npx expo install <package>, runnpm start -- --clearornpx expo start --clear. A plain restart leaves stale module resolution and causes icons or modules to fail silently until the cache is cleared. - Proxy all external API calls — never branch by platform for CORS. External REST API calls that work on native are silently blocked by CORS on Expo web. The fix is a proxy (e.g. a Supabase Edge Function) so both native and web use the same code path. A web-only fetch branch doubles the maintenance surface and will diverge.
- Emoji ignores colour styling in TextInput rows. Emoji characters inside
TextInputrows are invisible on dark backgrounds because emoji rendering ignores thecolorstyle prop. Use a short text label (SHOW/HIDE) with an explicitcolorinstead. Merge the label button into a shared bordered container usingflexDirection: 'row'to avoid an unwanted internal dividing line. - Test on both native and web targets before considering a screen complete. Behaviour differences between native and web (CORS, emoji rendering, font loading) only surface when the target is exercised — do not rely on native-only testing.
- Expo Go is no longer available for SDK 56+ — use EAS development builds. Expo Go was removed from the App Store for SDK 56 (confirmed May 2026). The correct local testing approach for iOS is an EAS development build with
expo-dev-client, including when developing on Windows against a physical device. - The iOS
iconfield inapp.config.jsmust point to a real PNG. The Expo default template may leave a placeholder (e.g../assets/expo.icon) that is not a valid image. Replace it with a real PNG path (e.g../assets/images/icon.png) before building, or the iOS build fails. - Use a
Modal-based dropdown when a list sits inside nested ScrollViews on Android. AScrollViewinside an absolutely-positionedViewthat is itself inside a parentScrollViewcannot receive scroll touches — the parent clips touch events regardless ofnestedScrollEnabled. The reliable fix:measureInWindowon the trigger ref to get screen coordinates, then render the list in aModalpositioned at those coordinates, placing it entirely outside the ScrollView hierarchy. - iOS
Modalcomponents ignore the app-level orientation lock. Expo'sorientation: 'portrait'config does not propagate to modals. EachModalmust explicitly declaresupportedOrientations={['portrait']}or it rotates when the device is held sideways or in Stage Manager on iPad. - On Windows, the Android emulator lives at
C:\Users\<username>\Android\sdk\emulator\emulator.exe— not%LOCALAPPDATA%\Android\Sdk\emulator. Multipleexpo startinvocations accumulate Metro instances across ports 8081, 8083, 8085…; kill all processes on ports 8081–8090 before starting a new Metro instance, then runadb reverse tcp:8081 tcp:8081so the emulator can reach Metro on the host. - Expo/React Native and Flet/Flutter are entirely separate stacks — no patterns transfer between them. Expo uses TypeScript → React Native → native platform UI (UIKit on iOS, Jetpack Compose on Android). Flet uses Python → Flutter (Dart) → canvas-based rendering. No runtime is shared; packages, debugging approaches, and build tooling are completely different.
- Choose Expo over Flet for production mobile unless Python familiarity is the only constraint. Expo's hot reload dev cycle, EAS build/signing/OTA tooling, native push notification support, and ecosystem size substantially outweigh Flet's sole advantage of Python. If the team is comfortable with TypeScript, choose Expo.
- Expo only exposes
EXPO_PUBLIC_*env vars to client code.app.config.jsextramust read the prefixed names (e.g.process.env.EXPO_PUBLIC_SUPABASE_URL), and CI workflows must inject under those names too. Reading the unprefixedSUPABASE_URLyields empty values and a runtime "supabaseUrl is required" crash. app.config.jsextraconstants are Metro-cached. After editingapp.config.js, the old values persist until you restart withnpx expo start --clear. A stale-config error that won't go away is usually this, not a code bug.- Consolidate per-app placeholders into one
template.config.json+ a generator script. The generator stampsapp.config.js/package.json/eas.json/ the CI workflow and regenerates a singlesrc/constants/AppConfig.tsthe screens import — killing hardcoded URL/name sprawl and making a new app one edit plus one command. - Push notifications require a development build, not Expo Go. Expo push was removed from Expo Go in SDK 53 (deprecated in 52) and only works in a dev build (
expo-dev-client). The iOS Simulator cannot receive push (physical device required); EAS provisions the APNs/FCM credentials. - Native in-app review is a silent no-op on sideloaded/dev builds.
expo-store-review(Play In-App Review /SKStoreReviewController) only renders on store-distributed installs (Android: any Play track, Internal testing is enough; iOS: TestFlight/App Store). On a dev APKrequestReview()does nothing — that is correct, not a bug. Verify it as a launch-time test on a real store track; never add a dev-only fallback to force it. - Lazy-load optional native modules with a synchronous
require(). Importing an optional native module (e.g.expo-store-review) at the top level crashes any dev build whose native binary predates the JS module ("Cannot find native module");await import()then trips a Metro async-require bug ("Requiring unknown module N"). Use a synchronousrequire()inside the function (with an eslint-disable forno-require-imports) to keep graceful degradation. - Reach Metro from a physical Android device over USB with
adb reverse. Runadb reverse tcp:8081 tcp:8081, start with plainnpx expo start --dev-client(default 0.0.0.0 binding), and in the dev launcher enterhttp://localhost:8081. Do not pass--localhost— it binds Metro to IPv6::1only, breaking the IPv4 adb-reverse tunnel ("unexpected end of stream" / UNAUTHORIZED). - On Windows under OneDrive, mark
node_modules"Always keep on this device". OneDrive's on-demand placeholder files make Metro bundling fail withreadlink EINVALuntil the files are materialised locally. - Scope App Links / Universal Links to the landing path only (
/<slug>,/<slug>/), never/<slug>/*. Broadening the pattern hijacks web-only auth pages (e.g./<slug>/reset-password,/<slug>/confirm-email) into the app and breaks them — those must complete in the browser. Links also stay inert untilassetlinks.json(Android, signing SHA-256) / AASA (iOS, Apple Team ID) are deployed to/.well-known/and the app is rebuilt. Alert.alertis a silent no-op on react-native-web. Message-type errors never appear on the web target — provide a cross-platform toast (a root-mounted host plus a plainshowToastfunction) for one-way messages, and keep nativeAlertonly for interactive prompts (action sheets, camera-permission dialogs).- Create
Animated.Values with a lazyuseState, notuseRef(...).current.useRef(new Animated.Value(0)).currenttrips the newerreact-hooks/refslint rule ("cannot access refs during render") — useuseState(() => new Animated.Value(0))for a value that is stable across renders without reading.currentduring render. - Resolve the current user via
getSession(), notgetUser(), in write paths. supabase-jsgetUser()makes a network round-trip and returns null when offline (surfacing a misleading "not signed in");getSession()reads the persisted local session and is offline-safe, so an offline action fails as a network error, not a false sign-out. - Supabase and fetch errors are frequently plain objects, not
Errorinstances.e instanceof Errormisses their.message— extract.messagefrom any object shape and map network strings ("failed to fetch", "network request failed") to a friendly line, so raw errors and the backend URL never reach users. - Make the server authoritative about onboarding. Return an explicit
needs_onboardingflag rather than inferring it client-side from a missing profile row — the client guess also fires on a transient read failure and wrongly bounces an onboarded user to the wizard. Self-heal the onboarding page too (redirect an already-onboarded user away) so a stale tab can't strand them there. - Persist the pending-confirmation email for an email-confirmation sign-up gate (email only, never the password) so closing and reopening the app mid-wait restores the "confirm your email" state instead of a blank login with full re-entry.
- A coloured badge inside a web form field is the browser's native autofill or a password-manager extension, not your code.
autocompleteattributes (given-name/family-name/tel/email, andoffon unique/credential fields) steer the browser's native autofill but cannot suppress a third-party extension (e.g. LastPass); confirm by loading the page in a private window. - The red bottom-left error popup on the Expo web target is Expo's dev-only notification. The dismissable lightning-bolt popup for a runtime or network error does not ship to production — don't mistake it for your own in-app toast or UI.
- Run the project's own
lintscript, notnpx eslintdirectly. A JS/TS project may lint with oxlint rather than eslint; invoking eslint applies a different (often stricter) ruleset and reports "errors" the project never gates on. - View a private Supabase Storage file with a signed URL +
WebBrowser.openBrowserAsync. Create a short-lived signed URL and open it withexpo-web-browser(usually already installed) rather than addingexpo-file-systemor a download step; open plain links withLinking.openURL. - Render tappable links inside message text with nested
<Text onPress>. Split the string on a URL regex and return the URL segments as nested<Text => Linking.openURL(url)}>within the parent<Text>— RN honours press handlers on nested Text runs, so you get inline links with no rich-text library. - When the website's URL path differs from the Expo
slug, use a separate web-path value. The slug is a technical id tied to the EAS project — don't derive web URLs or App-Link paths from it. IntroducewebSlug/extra.webPathfor the web path and keep the slug for builds/credentials;+native-intent.tsxshould stripextra.webPath, notConstants.expoConfig.slug, so the landing link resolves to the app root even when slug ≠ URL segment.
Process
- Set up font preloading in the root layout. For every icon set in use, call
useFonts(Iconset.font)at the root layout level. Gate the return of any component tree behindif (!fontsLoaded) return null. Do not render icon-dependent components beforefontsLoadedis true. - Install packages via
npx expo install. This selects the SDK-compatible version. After every install, restart with--clearto flush Metro's module cache. - Route all external API calls through a proxy. Create a Supabase Edge Function (or equivalent server-side proxy) that forwards requests to the external API. Call the proxy from both native and web — do not use
Platform.OSbranching for fetch logic. - Audit interactive UI for dark-theme compatibility. Check any component that uses emoji, icon characters, or colour-reliant elements against a dark background. Replace emoji with text labels or
@expo/vector-iconscomponents that respect thecolorprop. - Smoke test on web target. Run
npx expo start --weband exercise all screens. CORS errors, font failures, and silentAlert.alertno-ops that pass on native will surface here. - Harden auth/session paths.
getSession()for the current user in writes, object-shape error extraction with friendly network messages, server-drivenneeds_onboarding, persisted pending-confirmation email. - Lint with the project's script (
npm run lint), notnpx eslint.
Output format
- Setup checklist — font preloading wired, Metro cache strategy confirmed, proxy endpoint identified
- Screen-by-screen notes — any platform-specific issues found and how they were resolved
- Remaining work — items that need device testing beyond what the web target covers
Quality checklist
-
useFonts(Iconset.font)called for every icon set in the root layout - Rendering gated on
fontsLoadedbefore any icon-dependent component renders - Metro restarted with
--clearafter every new package install - All external API calls routed through a proxy — no
Platform.OSbranching for fetch - Interactive TextInput components use text labels, not emoji, for dark-theme compatibility
- Web target tested with
npx expo start --web - iOS
iconinapp.config.jspoints to a real PNG, not a template placeholder - iOS testing uses an EAS development build (
expo-dev-client), not Expo Go, on SDK 56+ - Portrait-locked iOS modals declare
supportedOrientations={['portrait']} - Nested-ScrollView dropdowns on Android use a
Modalrather than an inner ScrollView - Client env vars use the
EXPO_PUBLIC_*prefix inapp.config.jsextraand in CI - Push notifications, in-app review, and other store-only features tested on a real store track — not a dev build
- Optional native modules lazy-loaded via synchronous
require(), not top-level orawait import() - App Links / Universal Links scoped to the landing path only, with
assetlinks.json/AASA deployed - One-way messages use a cross-platform toast; native
Alertreserved for interactive prompts - Write paths resolve the user via
getSession(); errors extracted by shape, network strings mapped to friendly text - Onboarding driven by a server
needs_onboardingflag; onboarding page self-heals; pending-confirmation email persisted (never the password) -
Animated.Valuecreated via lazyuseState; projectlintscript used
Avoid
- Assuming vector icons work without
useFonts— they will render as blank boxes - Restarting Metro without
--clearafter package installs — stale cache causes silent module failures - Adding a web-only fetch branch for CORS — use a proxy that works on both targets
- Using emoji in TextInput interactive elements — emoji ignores
colorand disappears on dark backgrounds - Testing only on the native simulator — web-specific failures (CORS, font loading) only surface in the browser target
- Applying Flet/Flutter patterns, packages, or mental models to Expo development — they are entirely separate stacks with no shared runtime
- Relying on Expo Go for SDK 56+ iOS testing — it is no longer available; use an EAS development build with
expo-dev-client - Leaving the Expo template's placeholder icon path in
app.config.js— point the iOSiconfield at a real PNG before building - Nesting a scrollable list inside an absolutely-positioned
Viewwithin a parentScrollViewon Android — touch events are clipped; render the list in aModalinstead - Omitting
supportedOrientations={['portrait']}on iOS modals when the app is portrait-locked — modals rotate independently of the app config - Reading unprefixed env vars (
SUPABASE_URL) in client code — onlyEXPO_PUBLIC_*names reach the client; the rest are empty at runtime - Top-level importing an optional native module — it crashes dev builds whose native binary predates it; lazy-load with
require() - Broadening App Links to
/<slug>/*— it hijacks web-only auth pages into the app; scope to the landing path only - Passing
--localhosttoexpo startfor USB device debugging — it binds IPv6-only and breaks the adb-reverse tunnel - Using
Alert.alertfor error messages — it is a silent no-op on web; use a toast - Calling
getUser()in write paths — offline it returns null and reads as "signed out"; usegetSession() - Testing errors with
instanceof Error— Supabase/fetch errors are often plain objects; read.messageby shape - Inferring onboarding state client-side from a missing profile row — a transient read failure bounces onboarded users to the wizard
- Chasing a coloured autofill badge as a styling bug — it is the browser or a password-manager extension; check in a private window
- Mistaking Expo's dev-only red error popup on web for your own toast — it never ships
- Running
npx eslinton an oxlint project — wrong ruleset, phantom errors
Example usage
Starting a new Expo app — icons blank on first run, API calls working on iOS but failing on web, password toggle invisible in dark mode
Source: This skill is sourced from the Matrix Skills library. Learn more at the AI Agent Skills Library.