Expo Mobile Application Development Guide
IMPORTANT: This is a SKILL file, NOT a project. NEVER run npm/bun install in this folder. NEVER create code files here. When creating a new project, ALWAYS ask the user for the project path first or create it in a separate directory (e.g.,
~/Projects/app-name).
This guide is created to provide context when working with Expo projects using Claude Code.
MANDATORY REQUIREMENTS
When creating a new Expo project, you MUST include ALL of the following:
Required Screens (ALWAYS CREATE)
-
src/app/onboarding.tsx- Swipe-based onboarding with fullscreen background video and gradient overlay -
src/app/notification-permission.tsx- Pre-permission screen that explains notifications before the system prompt (addlocation-permission.tsxtoo if the app uses location) -
src/app/paywall.tsx- Paywall screen (shown after the permission screens) -
src/app/settings.tsx- Settings screen with language, theme, notifications, and reset onboarding options
Onboarding Video Implementation (REQUIRED)
The onboarding screen MUST have a fullscreen background video. Use a URL, not a local file:
tsximport { useVideoPlayer, VideoView } from "expo-video"; const VIDEO_URL = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"; const player = useVideoPlayer(VIDEO_URL, (player) => { player.loop = true; player.muted = true; player.play(); }); // In render: <VideoView player={player} style={StyleSheet.absoluteFill} contentFit="cover" nativeControls={false} />;
Do NOT just import expo-video without actually using the VideoView component.
Required Navigation (ALWAYS USE)
- Use
NativeTabsfromexpo-router/unstable-native-tabsfor tab navigation - NEVER use@react-navigation/bottom-tabsorTabsfrom expo-router
Required Context Providers (ALWAYS WRAP)
tsximport { ThemeProvider } from "@/context/theme-context"; import { DarkTheme, DefaultTheme, ThemeProvider as NavigationThemeProvider, } from "@react-navigation/native"; <ThemeProvider> <OnboardingProvider> <AdsProvider> <NavigationThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme} > <Stack /> </NavigationThemeProvider> </AdsProvider> </OnboardingProvider> </ThemeProvider>;
AdsProvider must be inside OnboardingProvider: it waits for onboarding to finish before showing the consent form / ATT prompt (see Ads Consent).
Required Libraries (ALWAYS INSTALL)
Use npx expo install to install libraries (NOT npm/yarn/bun install):
bashnpx expo install expo-iap react-native-purchases react-native-google-mobile-ads expo-tracking-transparency expo-notifications i18next react-i18next expo-localization react-native-reanimated expo-video expo-audio expo-sqlite expo-linear-gradient expo-store-review expo-updates expo-dev-client posthog-react-native expo-haptics
Libraries:
expo-iap(StoreKit 2 / Play Billing - purchases and premium check)react-native-purchases(RevenueCat - observer mode only: purchase records + Apple Ads attribution)react-native-google-mobile-ads(AdMob + Google UMP consent)expo-tracking-transparency(iOS ATT prompt)posthog-react-native(analytics, error tracking, feature flags)expo-store-review(native rating prompt)expo-updates(reload after RTL/LTR change),expo-dev-clientexpo-hapticsexpo-notificationsi18next+react-i18next+expo-localizationreact-native-reanimatedexpo-video+expo-audioexpo-sqlite(for localStorage)expo-linear-gradient(for gradient overlays)
AdMob Configuration (REQUIRED in app.json)
You MUST add this to app.json for AdMob to work:
json{ "expo": { "plugins": [ [ "react-native-google-mobile-ads", { "androidAppId": "ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy", "iosAppId": "ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy" } ] ] } }
For development/testing, use test App IDs:
- iOS:
ca-app-pub-3940256099942544~1458002511 - Android:
ca-app-pub-3940256099942544~3347511713
Do NOT skip this configuration or the app will crash with GADInvalidInitializationException.
Also add these plugins (ATT text is mandatory on iOS when ads can be personalised):
json[ "expo-tracking-transparency", { "userTrackingPermission": "Your permission lets us show ads that are more relevant to you. If you decline, you can keep using the app and will see less relevant ads." } ], "expo-iap"
Banner Ad Implementation (REQUIRED)
Put the banner in its own component and float it above the tab bar in the Tab layout:
tsx// src/components/banner-ad.tsx import { useState } from 'react'; import { View, StyleSheet } from 'react-native'; import { BannerAd, BannerAdSize, TestIds } from 'react-native-google-mobile-ads'; import { useAds } from '@/context/ads-context'; import { AD_UNIT_IDS } from '@/lib/ads'; export function AdBanner() { const { isAdsEnabled } = useAds(); const [loaded, setLoaded] = useState(false); if (!isAdsEnabled) return null; return ( // Hidden until filled, so an empty slot never takes space <View style={[styles.container, !loaded && styles.hidden]}> <BannerAd unitId={__DEV__ ? TestIds.BANNER : AD_UNIT_IDS.BANNER ?? TestIds.BANNER} size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER} // Personalisation follows the UMP + ATT choice; do NOT force requestNonPersonalizedAdsOnly onAdLoaded={() => setLoaded(true)} onAdFailedToLoad={() => setLoaded(false)} /> </View> ); } const styles = StyleSheet.create({ container: { alignItems: 'center', width: '100%' }, hidden: { opacity: 0, height: 0 }, });
tsx// src/app/(tabs)/_layout.tsx import { I18nManager, Platform, StyleSheet, View } from 'react-native'; import { usePathname } from 'expo-router'; import { NativeTabs } from 'expo-router/unstable-native-tabs'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; import { AdBanner } from '@/components/banner-ad'; const TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 49 : 56; export default function TabLayout() { const { t } = useTranslation(); const insets = useSafeAreaInsets(); // Hide the banner on screens where it would get in the way (camera, compass, full-screen media...) const showBanner = usePathname() !== '/camera'; return ( <View style={styles.container}> <NativeTabs // The native tab bar ignores React Native's RTL setting unstable_nativeProps={{ direction: I18nManager.isRTL ? 'rtl' : 'ltr' }} > <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Label>{t('tabs.home')}</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Icon sf="house.fill" md="home" /> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <NativeTabs.Trigger.Label>{t('tabs.settings')}</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Icon sf="gear" md="settings" /> </NativeTabs.Trigger> </NativeTabs> {showBanner && ( <View style={[styles.banner, { bottom: TAB_BAR_HEIGHT + insets.bottom }]}> <AdBanner /> </View> )} </View> ); } const styles = StyleSheet.create({ container: { flex: 1 }, banner: { position: 'absolute', left: 0, right: 0, alignItems: 'center' }, });
- ALWAYS use
TestIds.BANNERin development - Banner floats right above the tab bar (
TAB_BAR_HEIGHT + insets.bottom); give scroll content enough bottom padding useAds().isAdsEnabledis false for premium users and until consent + SDK init are done
Ads Consent: UMP + ATT (REQUIRED)
Ads must not be initialised before consent. Order: Google UMP form (EU/UK GDPR) → iOS ATT prompt → mobileAds().initialize(). Run it only after onboarding is finished, so it does not stack on top of the permission screens, and skip it entirely for premium users.
ts// src/lib/ads-consent.ts import { Platform } from 'react-native'; import { AdsConsent, AdsConsentDebugGeography, AdsConsentPrivacyOptionsRequirementStatus } from 'react-native-google-mobile-ads'; import * as TrackingTransparency from 'expo-tracking-transparency'; // Test the EU form from anywhere: EXPO_PUBLIC_ADS_DEBUG_EEA=1 bun start (simulator / test devices only) const DEBUG_EEA = __DEV__ && process.env.EXPO_PUBLIC_ADS_DEBUG_EEA === '1'; export async function gatherAdsConsent() { let canRequestAds = false; let privacyOptionsRequired = false; try { const info = await AdsConsent.gatherConsent(DEBUG_EEA ? { debugGeography: AdsConsentDebugGeography.EEA } : undefined); canRequestAds = info.canRequestAds; privacyOptionsRequired = info.privacyOptionsRequirementStatus === AdsConsentPrivacyOptionsRequirementStatus.REQUIRED; } catch { // Offline: fall back to the previous session's decision canRequestAds = (await AdsConsent.getConsentInfo().catch(() => null))?.canRequestAds ?? false; } // ATT only if ads will be shown; declining still shows (non-personalised) ads if (canRequestAds && Platform.OS === 'ios' && TrackingTransparency.isAvailable()) { const { status } = await TrackingTransparency.getTrackingPermissionsAsync(); if (status === 'undetermined') await TrackingTransparency.requestTrackingPermissionsAsync(); } return { canRequestAds, privacyOptionsRequired }; } export const showAdsPrivacyOptions = () => AdsConsent.showPrivacyOptionsForm();
In AdsProvider:
- Check premium first (cached value, then store) - premium users never see the consent form
- After
gatherAdsConsent()returnscanRequestAds, callmobileAds().setRequestConfiguration({ maxAdContentRating: MaxAdContentRating.PG, ... })thenmobileAds().initialize() - Expose
isAdsEnabled,isPremium,privacyOptionsRequired,showPrivacyOptions,refreshPremiumStatus - Keep ads OFF in development by default (
__DEV__ && process.env.EXPO_PUBLIC_ADS_DEV !== '1') - Settings MUST show a "Privacy choices" row when
privacyOptionsRequiredis true (EU rule) - The consent form content is configured in AdMob console → Privacy & messaging
TURKISH LOCALIZATION (IMPORTANT)
When writing tr.json, you MUST use correct Turkish characters:
- ı (lowercase dotless i) - NOT i
- İ (uppercase dotted I) - NOT I
- ü, Ü, ö, Ö, ç, Ç, ş, Ş, ğ, Ğ
Example:
- ✅ "Ayarlar", "Giriş", "Çıkış", "Başla", "İleri", "Güncelle"
- ❌ "Ayarlar", "Giris", "Cikis", "Basla", "Ileri", "Guncelle"
FORBIDDEN (NEVER USE)
- ❌ AsyncStorage - Use
expo-sqlite/localStorage/installinstead - ❌ lineHeight style - Use padding/margin instead
- ❌
Tabsfrom expo-router - UseNativeTabsinstead - ❌
@react-navigation/bottom-tabs- UseNativeTabsinstead - ❌
expo-av- Useexpo-videofor video,expo-audiofor audio instead - ❌
expo-ads-admob- Usereact-native-google-mobile-adsinstead - ❌ Any other ads library - ONLY use
react-native-google-mobile-ads - ❌ Reanimated hooks inside callbacks - Call at component top level
- ❌ Hardcoded prices or discount badges on the paywall - Always use store-formatted prices (
displayPrice) - ❌ Calling
mobileAds().initialize()before UMP consent - ❌ Unsupervised
react-native-purchasespurchase calls (purchasePackage) - RevenueCat runs in observer mode, purchases go throughexpo-iap
Reanimated Usage (IMPORTANT)
NEVER call useAnimatedStyle, useSharedValue, or other reanimated hooks inside callbacks, loops, or conditions.
❌ WRONG:
tsxconst renderItem = () => { const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 })); // ERROR! return <Animated.View style={animatedStyle} />; };
✅ CORRECT:
tsxfunction MyComponent() { const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 })); // Top level return <Animated.View style={animatedStyle} />; }
For lists, create a separate component for each item:
tsxfunction AnimatedItem({ item }) { const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 })); return <Animated.View style={animatedStyle}>{item.name}</Animated.View>; } // In FlatList: renderItem={({ item }) => <AnimatedItem item={item} />}
POST-CREATION CLEANUP (ALWAYS DO)
After creating a new Expo project, you MUST:
- If using
(tabs)folder, DELETEsrc/app/index.tsxto avoid route conflicts:
bashrm src/app/index.tsx
- Check and remove
lineHeightfrom these files:
src/components/themed-text.tsx(comes with lineHeight by default - REMOVE IT)- Any other component using
lineHeight
Search and remove all lineHeight occurrences:
bashgrep -r "lineHeight" src/
Replace with padding or margin instead.
AFTER COMPLETING CODE (ALWAYS RUN)
When you finish writing/modifying code, you MUST run these commands in order:
bashnpx expo install --fix npx expo prebuild --clean
install --fixfixes dependency version mismatchesprebuild --cleanrecreates ios and android folders
Do NOT skip these steps.
Project Creation
When user asks to create an app, you MUST:
- FIRST ask for the bundle ID (e.g., "What is the bundle ID? Example: com.company.appname")
- Create the project in the CURRENT directory using:
bashbunx create-expo -t default@next app-name
The current baseline is Expo SDK 57 / React Native 0.86 / React 19.2 (New Architecture only). Enable the React Compiler and typed routes in app.json:
json{ "expo": { "experiments": { "typedRoutes": true, "reactCompiler": true }, "ios": { "infoPlist": { "ITSAppUsesNonExemptEncryption": false, "CFBundleAllowMixedLocalizations": true } } } }
- Update
app.jsonwith the bundle ID:
json{ "expo": { "ios": { "bundleIdentifier": "com.company.appname" }, "android": { "package": "com.company.appname" } } }
- Then cd into the project and start implementing all required screens
- Do NOT ask for project path - always use current directory
Technology Stack
- Framework: Expo, React Native
- Navigation: Expo Router (file-based routing), NativeTabs
- State Management: React Context API
- Translations: i18next, react-i18next
- Purchases: StoreKit 2 / Play Billing via
expo-iap(source of truth for premium) - Purchase records & Apple Ads attribution: RevenueCat in observer mode (
react-native-purchases) - Advertisements: Google AdMob (react-native-google-mobile-ads) + UMP consent + ATT
- Analytics: PostHog (
posthog-react-native, EU host) - events, screens, error tracking, feature flags - Ratings:
expo-store-review - Notifications: expo-notifications
- Animations: react-native-reanimated
- Storage: localStorage via expo-sqlite polyfill
WARNING: DO NOT USE AsyncStorage! Use expo-sqlite polyfill instead.
- Example usage
jsimport "expo-sqlite/localStorage/install"; globalThis.localStorage.setItem("key", "value"); console.log(globalThis.localStorage.getItem("key")); // 'value'
WARNING: NEVER USE
lineHeight! It causes layout issues in React Native. Use padding or margin instead.
Project Structure
project-root/ ├── src/ │ ├── app/ │ │ ├── _layout.tsx │ │ ├── index.tsx │ │ ├── explore.tsx │ │ ├── settings.tsx │ │ ├── paywall.tsx │ │ ├── notification-permission.tsx │ │ └── onboarding.tsx │ ├── components/ │ │ ├── ui/ │ │ ├── themed-text.tsx │ │ └── themed-view.tsx │ ├── constants/ │ │ ├── theme.ts │ │ └── [data-files].ts │ ├── context/ │ │ ├── onboarding-context.tsx │ │ └── ads-context.tsx │ ├── hooks/ │ │ ├── use-notifications.ts │ │ └── use-color-scheme.ts │ ├── lib/ │ │ ├── notifications.ts │ │ ├── purchases.ts # expo-iap (StoreKit 2) │ │ ├── revenuecat.ts # observer mode │ │ ├── premium-cache.ts │ │ ├── ads.ts │ │ ├── ads-consent.ts # UMP + ATT │ │ ├── analytics.ts # PostHog │ │ ├── rating.ts # expo-store-review │ │ └── i18n.ts │ └── locales/ │ ├── tr.json │ └── en.json ├── assets/ │ └── images/ ├── ios/ ├── android/ ├── app.json ├── eas.json ├── store.config.json # EAS Metadata (App Store listing) ├── package.json └── tsconfig.json
Tab Navigation (NativeTabs)
Expo Router uses NativeTabs for native tab navigation:
tsximport { NativeTabs } from "expo-router/unstable-native-tabs"; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Icon sf="house.fill" md="home" /> </NativeTabs.Trigger> <NativeTabs.Trigger name="explore"> <NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Icon sf="compass.fill" md="explore" /> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Icon sf="gear" md="settings" /> </NativeTabs.Trigger> </NativeTabs> ); }
NativeTabs Properties
- sf: SF Symbols icon name (iOS)
- md: Material Design icon name (Android)
- name: Route file name
- Tab order follows trigger order
- RTL: pass
unstable_nativeProps={{ direction: I18nManager.isRTL ? 'rtl' : 'ltr' }}, the native bar does not followI18nManageron its own - If NativeTabs misbehaves on Android, fall back to expo-router
Tabson Android only (if (Platform.OS === 'ios') return <NativeTabs>...) withIconSymbolicons and a haptic tab button; iOS always uses NativeTabs
Common Icons
| Purpose | SF Symbol | Material Icon |
|---|---|---|
| Home | house.fill | home |
| Explore | compass.fill | explore |
| Settings | gear | settings |
| Profile | person.fill | person |
| Search | magnifyingglass | search |
| Favorites | heart.fill | favorite |
| Notifications | bell.fill | notifications |
Development Commands
bashbun install bun start bun ios bun android bun lint npx expo install --fix npx expo prebuild --clean
EAS Build Commands
eas.json baseline:
json{ "cli": { "version": ">= 16.28.0", "appVersionSource": "remote", "promptToConfigurePushNotifications": false }, "build": { "development": { "developmentClient": true, "distribution": "internal" }, "preview": { "distribution": "internal" }, "production": { "autoIncrement": true } }, "submit": { "production": { "ios": { "ascAppId": "<APP_STORE_CONNECT_APP_ID>" } } } }
basheas build --profile development --platform ios eas build --profile development --platform android eas build --profile production --platform ios eas build --profile production --platform android eas submit --platform ios eas submit --platform android eas metadata:push # push store.config.json (title, subtitle, keywords, description per locale)
Important Modules
Purchases: StoreKit 2 via expo-iap + RevenueCat observer mode
src/lib/purchases.ts: everything about buying and premium goes throughexpo-iap(initConnection,fetchProducts,requestPurchase,finishTransaction,getActiveSubscriptions,getAvailablePurchases,restorePurchases)src/lib/revenuecat.ts: RevenueCat is configured withpurchasesAreCompletedBy: { type: PURCHASES_ARE_COMPLETED_BY_TYPE.MY_APP, storeKitVersion: STOREKIT_VERSION.STOREKIT_2 }. It only records purchases (Purchases.recordPurchase(productId)) and Apple Ads attribution (enableAdServicesAttributionTokenCollection(), no ATT needed). It never decides premium; if RevenueCat is down, purchases still work- Products:
weekly+yearlysubscriptions (same subscription group) and an optionallifetimenon-consumable - Premium = an active subscription OR a lifetime purchase. Cache the last value (
premium-cache.ts) so background tasks can read it without hitting the store
ts// Listen once at startup; renewals, interrupted purchases and Ask to Buy approvals arrive here too. // Unfinished transactions are re-delivered on every launch. purchaseUpdatedListener((purchase) => { if (purchase.purchaseState === 'pending') return notify(purchase); // Ask to Buy: wait for approval void recordRevenueCatPurchase(purchase.productId) // with a ~5s timeout, before finishing .then(() => finishTransaction({ purchase, isConsumable: false })) .finally(() => notify(purchase)); }); // Purchase requestPurchase({ request: { apple: { sku: productId }, google: { skus: [productId] } }, type: productId === PRODUCT_IDS.LIFETIME ? 'in-app' : 'subs', }); // Errors come from purchaseErrorListener; ErrorCode.UserCancelled => 'cancelled', not an error alert
- Result type:
'purchased' | 'pending' | 'cancelled' | 'failed'; onlyfailedshows an error alert - Free trial: show it only if
isEligibleForIntroOfferIOS(subscriptionGroupId)is true; derive days fromintroductoryPricePaymentModeIOS === 'free-trial'+ period - Add a
.storekitconfiguration file to the Xcode scheme to test purchases on the simulator - RevenueCat attribute
$posthogUserId= PostHog distinct id, so purchases join analytics users
AdMob
- Files:
src/lib/ads.ts(unit IDs per platform),src/lib/ads-consent.ts,src/context/ads-context.tsx,src/components/banner-ad.tsx - Ads disabled for premium users and in development (opt in with
EXPO_PUBLIC_ADS_DEV=1) - Test IDs must be used in development
Onboarding & Paywall Flow (CRITICAL)
Flow: Onboarding → Permission screens (location?, notifications) → Paywall → Main App (tabs)
- Each step has a persisted flag in
OnboardingProvider(hasCompletedOnboarding,hasShownNotificationPermission,hasShownPaywall, ... andcompleteX()setters) - The root
_layout.tsxis the single router guard: an effect watches the flags +useSegments()androuter.replaces to the first unfinished step - Until the current segment matches the expected step, render a loading view instead of the
Stack(prevents a flash of the tabs) - Permission screens explain the benefit first, then call the system prompt; "Not now" also completes the step
- The paywall knows it is in the onboarding flow via
!hasShownPaywall: closing it callscompletePaywall()+router.replace('/'). Opened later (settings, a locked feature) it just doesrouter.back(). Pass?from=settings|featurefor analytics
tsxuseEffect(() => { if (isLoading) return; const step = segments[0]; if (!hasCompletedOnboarding && step !== 'onboarding') router.replace('/onboarding'); else if (hasCompletedOnboarding && !hasShownNotificationPermission && step !== 'notification-permission') router.replace('/notification-permission'); else if (hasShownNotificationPermission && !hasShownPaywall && step !== 'paywall') router.replace('/paywall'); }, [isLoading, hasCompletedOnboarding, hasShownNotificationPermission, hasShownPaywall, segments]);
Paywall (REQUIRED)
Plans, all with prices from the store (never hardcoded):
- Weekly
- Yearly - selected by default and highlighted; badge shows the real saving computed from store prices (
1 - yearly.price / (weekly.price * 52)), and the free trial if eligible - Lifetime (optional non-consumable)
tsxconst [selectedPlan, setSelectedPlan] = useState<'weekly' | 'yearly' | 'lifetime'>('yearly'); const [plans, setPlans] = useState<Plan[]>([]); // { id, displayPrice, price, currency, trialDays } useEffect(() => { getPlans().then(setPlans); // open the page immediately, fill prices when they arrive }, []);
App Store review (Guideline 3.1.2) requirements - the paywall MUST have:
- A visible close button (and onboarding must continue without buying)
- Restore Purchases button
- Price and period for every plan, trial length and what it renews into
- Auto-renewal text ("cancel anytime in App Store settings")
- Tappable Privacy Policy and Terms of Use (EULA) links (same links in the App Store description)
- After success:
refreshPremiumStatus()fromuseAds(), then continue the flow / go back
Settings Screen Options (REQUIRED)
Settings screen MUST include:
- Language - Change app language
- Theme - Light/Dark/System
- Notifications - Enable/disable notifications
- Remove Ads - Navigate to paywall (hidden if already premium)
- Restore Purchases
- Privacy choices -
showPrivacyOptions()(only whenprivacyOptionsRequired, EU) - Analytics toggle - PostHog opt-in/opt-out (only when analytics is configured)
- Rate / Share / Privacy Policy / Terms / Support links
- Reset Onboarding - Restart onboarding flow (
__DEV__only)
tsxconst { isPremium } = useAds(); // Remove Ads - navigates to paywall const handleRemoveAds = () => { router.push('/paywall'); }; // Reset onboarding const handleResetOnboarding = async () => { await setOnboardingCompleted(false); router.replace('/onboarding'); }; // In settings list: {!isPremium && ( <SettingsItem title={t('settings.removeAds')} icon="crown.fill" onPress={handleRemoveAds} /> )} <SettingsItem title={t('settings.resetOnboarding')} icon="arrow.counterclockwise" onPress={handleResetOnboarding} />
Localization
- File:
src/lib/i18n.ts, strings insrc/locales/*.json - Initial language: walk
Localization.getLocales()in order and take the first supported one (a user's second language may be supported), fallbacken - Persist the user's choice and load it on startup
- Translate iOS permission texts / app name with
expo.localesinapp.json({ "tr": "./locales/ios/tr.json" }) +CFBundleAllowMixedLocalizations: true
RTL (Arabic, Hebrew, Persian, Urdu)
Switch direction at runtime when the language changes:
tsI18nManager.allowRTL(rtl); I18nManager.forceRTL(rtl); if (Platform.OS === 'ios') Settings.set({ AppleLanguages: [lang] }); // native strings, share sheet, alerts if (I18nManager.isRTL !== rtl) { // Guard with a stored flag so a failed reload cannot loop if (__DEV__) DevSettings.reload(); else await Updates.reloadAsync(); }
- Use
start/end(marginStart,paddingEnd) instead ofleft/right - Flip directional icons (chevrons, arrows) with
transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }]
Analytics (PostHog)
File: src/lib/analytics.ts
tsconst posthog = process.env.EXPO_PUBLIC_POSTHOG_KEY ? new PostHog(process.env.EXPO_PUBLIC_POSTHOG_KEY, { host: process.env.EXPO_PUBLIC_POSTHOG_HOST ?? 'https://eu.i.posthog.com', disabled: __DEV__ && process.env.EXPO_PUBLIC_POSTHOG_DEV !== '1', captureAppLifecycleEvents: true, errorTracking: { autocapture: { uncaughtExceptions: true, unhandledRejections: true, nativeCrashes: true } }, }) : null; type Events = { onboarding_completed: { skipped: boolean }; paywall_viewed: { from: 'onboarding' | 'settings' | 'feature' }; purchase_started: { plan: 'weekly' | 'yearly' | 'lifetime' }; purchase_completed: { plan: 'weekly' | 'yearly' | 'lifetime'; revenue?: number; currency?: string; trial?: boolean }; purchase_failed: { plan: 'weekly' | 'yearly' | 'lifetime'; reason: 'pending' | 'cancelled' | 'failed' }; restore_completed: { active: boolean }; // ...one entry per product event }; export function track<E extends keyof Events>(event: E, properties: Events[E]) { posthog?.capture(event, properties); } export const trackScreen = (name: string) => void posthog?.screen(name);
- Typed event map - no free-form event names
- Track screens from the root layout:
useEffect(() => trackScreen(pathname), [pathname]) - Track notification opens with
Notifications.useLastNotificationResponse()(dedupe by id + date) - Anonymous only: no PII, no exact coordinates, no user-typed text
- Feature flags (
posthog.getFeatureFlag) for remote config / A-B tests; always have a default - Keys live in
.env(EXPO_PUBLIC_POSTHOG_KEY,EXPO_PUBLIC_POSTHOG_HOST), never committed
Ratings (expo-store-review)
File: src/lib/rating.ts
- Ask at a value moment (task completed, goal reached), never on launch or after an error
- Conditions: app opened on at least 3 different days, 60+ days since the last request
- Delay ~1.2s so the success animation finishes, check
StoreReview.isAvailableAsync(), thenStoreReview.requestReview() - iOS shows it at most 3 times a year and does not report whether it was shown
- Settings "Rate us" opens
https://apps.apple.com/app/id<ID>?action=write-reviewinstead
Notifications
- Files:
src/lib/notifications.ts,src/hooks/use-notifications.ts - Remote push needs the
aps-environmententitlement; local notifications do not - iOS keeps at most 64 pending local notifications: schedule a rolling window and re-sync on every
AppState→activeand from the background task - Custom sounds: list
.caf(iOS) /.wavfiles under theexpo-notificationspluginsoundsoption, refer to them by file name; keep them under 30 s - Ask permission on the pre-permission screen, not on launch
Background Tasks
ts// src/lib/background-sync.ts - imported from app/_layout.tsx so defineTask runs at module load TaskManager.defineTask(TASK_NAME, async () => { try { await syncScheduledContent(); return BackgroundTask.BackgroundTaskResult.Success; } catch { return BackgroundTask.BackgroundTaskResult.Failed; } }); await BackgroundTask.registerTaskAsync(TASK_NAME, { minimumInterval: 60 }); // minutes, a lower bound only
- Packages:
expo-background-task+expo-task-manager - iOS decides when it runs (no guarantee); the real refresh happens on foreground, the task is a safety net
- Never touch UI-only APIs (RTL reload, prompts) from a task
Widgets & Live Activities (iOS, optional)
expo-widgets+@expo/ui/swift-ui: widgets are JSX functions with a'widget'directive; they run in the extension, so no hooks, imports or outer constants inside - everything comes from props- Configure in
app.jsonunder theexpo-widgetsplugin (groupIdentifier, widgetname,supportedFamilies) and use the same App Group in code - Push data with
Widget.updateTimeline(entries)(precompute several days of entries so the widget keeps working when the app is not opened) - Extensions can only read files from the App Group container: copy images there (
Paths.appleSharedContainers[APP_GROUP]), version the file name when the image changes - Live Activities (iOS 16.2+) can only be started while the app is in the foreground; background tasks may only update a running one
Coding Standards
- Use functional components
- Strict TypeScript
- Avoid hardcoded strings
- File names kebab-case (
use-prayer-times.ts), components PascalCase - Unit tests for pure logic with
bun test("test": "bun test",@types/bun) - Use padding instead of lineHeight
- Use memoization when necessary
Context Providers
tsx<ThemeProvider> <OnboardingProvider> <AdsProvider> <Stack /> </AdsProvider> </OnboardingProvider> </ThemeProvider>
useColorScheme Hook
File: src/hooks/use-color-scheme.ts
tsximport { useThemeContext } from '@/context/theme-context'; export function useColorScheme(): 'light' | 'dark' | 'unspecified' { const { isDark } = useThemeContext(); return isDark ? 'dark' : 'light'; }
Important Notes
- iOS permissions are defined in
app.json - Android permissions are defined in
app.json - New Architecture is the only architecture since SDK 55 - no
newArchEnabledflag needed - Enable typed routes and the React Compiler via
experiments.typedRoutes/experiments.reactCompiler - Secrets and keys go to
.envasEXPO_PUBLIC_*(and EAS environment variables for builds)
App Store & Play Store Notes
- iOS ATT permission required before personalised ads (after the UMP form)
- Restore purchases must work correctly
- Paywall must meet Guideline 3.1.2 (see Paywall section); App Store description must also include the auto-renew text, Privacy Policy and Terms (EULA) links
- Do not request permissions the app does not use (5.1.1); every permission needs a purpose string
ITSAppUsesNonExemptEncryption: falseto skip the export compliance question- Store listing lives in
store.config.json(EAS Metadata), localized per language - Target SDK must be up to date
Store Screenshots
When the user asks for App Store / Google Play screenshots or store visuals, ALWAYS use the /app-store-screenshot skill (invoke it with the Skill tool) instead of building the images by hand.
- Take raw screenshots from the simulator (
xcrun simctl io booted screenshot <file>.png), one per key screen, in every store language - Run
/app-store-screenshotwith those screenshots; it adds the device frame, background and headline while keeping the app UI pixel-exact - Keep the output in
store/screenshots/next tostore.config.json
Testing Checklist
- UI tested in all languages
- Dark / Light mode
- Notifications
- Premium flow (StoreKit config file on simulator, Sandbox account on device)
- Free trial eligibility, Ask to Buy (pending), cancelled purchase
- Restore purchases
- EU consent form (
EXPO_PUBLIC_ADS_DEBUG_EEA=1) and ATT prompt - RTL layout
- Offline support
- Multiple screen sizes
After Development
bashnpx expo prebuild --clean bun ios bun android
NOTE:
prebuild --cleanrecreates ios and android folders. Run it after modifying native modules or app.json.


