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/att-permission.tsx- App Tracking Transparency permission screen (iOS only, shown BEFORE onboarding) -
src/app/onboarding.tsx- Swipe-based onboarding with fullscreen background video and gradient overlay -
src/app/paywall.tsx- expo-iap paywall screen (shown after onboarding) -
src/app/settings.tsx- Settings screen with language, theme, notifications, and reset onboarding options
Onboarding Screen Implementation (REQUIRED)
The onboarding screen MUST have a fullscreen background video. Use a local asset (require("@/assets/...")). The video is looped, muted, and played automatically.
Full implementation of src/app/onboarding.tsx:
import { useOnboarding } from "@/context/onboarding-context";
import { MaterialIcons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { router } from "expo-router";
import { useVideoPlayer, VideoView } from "expo-video";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dimensions,
FlatList,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
const VIDEO_SOURCE = require("@/assets/onboarding.mp4");
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const SLIDES = [
{
key: "1",
titleKey: "onboarding.slide1.title",
descKey: "onboarding.slide1.description",
icon: "access-time",
},
{
key: "2",
titleKey: "onboarding.slide2.title",
descKey: "onboarding.slide2.description",
icon: "explore",
},
{
key: "3",
titleKey: "onboarding.slide3.title",
descKey: "onboarding.slide3.description",
icon: "calendar-today",
},
{
key: "4",
titleKey: "onboarding.slide4.title",
descKey: "onboarding.slide4.description",
icon: "lock",
},
];
export default function OnboardingScreen() {
const { t } = useTranslation();
const { setOnboardingCompleted } = useOnboarding();
const [activeIndex, setActiveIndex] = useState(0);
const flatListRef = useRef<FlatList>(null);
const player = useVideoPlayer(VIDEO_SOURCE, (p) => {
p.loop = true;
p.muted = true;
p.play();
});
const handleNext = () => {
if (activeIndex < SLIDES.length - 1) {
flatListRef.current?.scrollToIndex({
index: activeIndex + 1,
animated: true,
});
setActiveIndex(activeIndex + 1);
} else {
handleComplete();
}
};
const handleComplete = async () => {
await setOnboardingCompleted(true);
router.replace("/paywall");
};
const isLast = activeIndex === SLIDES.length - 1;
return (
<View style={styles.container}>
{/* Background video */}
<VideoView
player={player}
style={StyleSheet.absoluteFill}
contentFit="cover"
nativeControls={false}
/>
{/* Gradient overlay */}
<LinearGradient
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.9)"]}
style={StyleSheet.absoluteFill}
/>
<SafeAreaView style={styles.safeArea}>
{/* Skip button */}
<View style={styles.topBar}>
<TouchableOpacity style={styles.skipButton}>
<Text style={styles.skipButtonText}>{t("onboarding.skip")}</Text>
</TouchableOpacity>
</View>
{/* Slides */}
<FlatList
ref={flatListRef}
data={SLIDES}
horizontal
pagingEnabled
scrollEnabled
showsHorizontalScrollIndicator={false}
keyExtractor={(item) => item.key}
=> {
const index = Math.round(
e.nativeEvent.contentOffset.x / SCREEN_WIDTH,
);
setActiveIndex(index);
}}
renderItem={({ item }) => (
<View style={styles.slide}>
<View
style={{
width: 96,
height: 96,
borderRadius: 48,
alignItems: "center",
justifyContent: "center",
marginBottom: 32,
backgroundColor: "rgba(65,114,157,0.35)",
borderWidth: 1.5,
borderColor: "rgba(65,114,157,0.6)",
}}
>
<MaterialIcons
name={item.icon as any}
size={52}
color="#FFFFFF"
/>
</View>
<Text style={styles.slideTitle}>{t(item.titleKey)}</Text>
<Text style={styles.slideDesc}>{t(item.descKey)}</Text>
</View>
)}
/>
{/* Dots */}
<View style={styles.dotsContainer}>
{SLIDES.map((_, i) => (
<View
key={i}
style={[
styles.dot,
i === activeIndex ? styles.dotActive : styles.dotInactive,
]}
/>
))}
</View>
{/* CTA */}
<View style={styles.ctaContainer}>
<TouchableOpacity style={styles.ctaButton}>
<Text style={styles.ctaButtonText}>
{isLast ? t("onboarding.getStarted") : t("onboarding.next")}
</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#000" },
safeArea: { flex: 1 },
topBar: {
flexDirection: "row",
justifyContent: "flex-end",
paddingHorizontal: 20,
paddingTop: 8,
},
slide: {
width: SCREEN_WIDTH,
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 40,
},
skipButton: {
borderWidth: 1,
borderColor: "rgba(255,255,255,0.25)",
backgroundColor: "rgba(255,255,255,0.15)",
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 8,
},
skipButtonText: {
color: "rgba(255,255,255,0.85)",
fontSize: 13,
fontWeight: "600",
},
slideTitle: {
fontSize: 36,
fontWeight: "700",
color: "#FFFFFF",
textAlign: "center",
marginBottom: 16,
},
slideDesc: {
fontSize: 17,
color: "rgba(255,255,255,0.75)",
textAlign: "center",
},
dotsContainer: {
flexDirection: "row",
justifyContent: "center",
gap: 8,
marginBottom: 24,
},
dot: {
height: 8,
borderRadius: 4,
},
dotActive: {
width: 24,
backgroundColor: "#FFFFFF",
},
dotInactive: {
width: 8,
backgroundColor: "rgba(255,255,255,0.3)",
},
ctaContainer: {
paddingHorizontal: 24,
paddingBottom: 40,
},
ctaButton: {
width: "100%",
backgroundColor: "#6C63FF",
borderRadius: 16,
alignItems: "center",
paddingVertical: 16,
},
ctaButtonText: {
color: "#FFFFFF",
fontSize: 18,
fontWeight: "700",
},
});
Notes:
- Place your onboarding video at
assets/onboarding.mp4(adjust therequirepath to match the actual file)SafeAreaViewis fromreact-native-safe-area-context, NOTreact-native- Slide icons use
@expo/vector-iconsMaterialIcons— adjust icon names per app theme- Slides array and icon names should be customized per app
- Add required i18n keys:
onboarding.slide1.title,onboarding.slide1.description, etc., plusonboarding.skip,onboarding.next,onboarding.getStarted
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)
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { ThemeProvider } from "@/context/theme-context";
import { PurchasesProvider } from "@/context/purchases-context";
import {
DarkTheme,
DefaultTheme,
ThemeProvider as NavigationThemeProvider,
} from "@react-navigation/native";
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider>
<OnboardingProvider>
<PurchasesProvider>
<AdsProvider>
<NavigationThemeProvider
value={colorScheme === "dark" ? DarkTheme : DefaultTheme}
>
<Stack />
</NavigationThemeProvider>
</AdsProvider>
</PurchasesProvider>
</OnboardingProvider>
</ThemeProvider>
</GestureHandlerRootView>;
Required Libraries (ALWAYS INSTALL)
Use npx expo install to install Expo libraries (NOT npm/yarn/bun install).
Use bun add for non-Expo libraries:
# Expo libraries
npx expo install expo-iap expo-build-properties expo-tracking-transparency react-native-google-mobile-ads expo-notifications i18next react-i18next expo-localization react-native-reanimated expo-video expo-audio expo-sqlite expo-linear-gradient
# Peer dependencies
npx expo install react-native-screens react-native-reanimated react-native-gesture-handler react-native-safe-area-context react-native-svg
Libraries:
expo-iap(In-App Purchases)expo-build-properties(required by expo-iap)expo-tracking-transparency(ATT — iOS App Tracking Transparency)react-native-google-mobile-ads(AdMob)expo-notificationsi18next+react-i18next+expo-localizationreact-native-reanimatedexpo-video+expo-audioexpo-sqlite(for localStorage)expo-linear-gradient(for gradient overlays)
expo-iap Configuration (REQUIRED in app.json)
You MUST add this to app.json for expo-iap to work (Expo SDK 53+):
{
"expo": {
"plugins": [
"expo-iap",
["expo-build-properties", { "android": { "kotlinVersion": "2.2.0" } }]
]
}
}
- Requires Expo SDK 53+ or React Native 0.79+
- iOS 15+ (StoreKit 2), Android API 21+
- Does NOT work in Expo Go — use custom dev client (
eas build --profile development)
AdMob Configuration (REQUIRED in app.json)
You MUST add this to app.json for AdMob to work:
{
"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.
Ad Strategy (Revenue-Optimised, UX-Friendly)
Use all five AdMob formats for maximum revenue with minimal UX friction:
| Format | Trigger | Cooldown | Premium Hidden |
|---|---|---|---|
| App Open | App foreground (after first launch) | 4 hours | ✅ |
| Banner | Tab bar, always visible | None | ✅ |
| Native | In-feed, every 5 items in FlatList | None | ✅ |
| Interstitial | After key user action | 3 minutes / max 3/day | ✅ |
| Rewarded | User-initiated, for a benefit | User-triggered | ✅ |
All ad formats are hidden for premium users via shouldShowAds.
AdsProvider Implementation (REQUIRED)
Create src/context/ads-context.tsx:
import React, {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { AppState, AppStateStatus } from "react-native";
import {
AdEventType,
AppOpenAd,
InterstitialAd,
RewardedAd,
RewardedAdEventType,
TestIds,
} from "react-native-google-mobile-ads";
import { usePurchases } from "@/context/purchases-context";
import "expo-sqlite/localStorage/install";
// ── Ad Unit IDs ──────────────────────────────────────────────
export const AD_UNITS = {
banner: __DEV__ ? TestIds.BANNER : "ca-app-pub-xxxxxxxxxxxxxxxx/BANNER_ID",
interstitial: __DEV__
? TestIds.INTERSTITIAL
: "ca-app-pub-xxxxxxxxxxxxxxxx/INTERSTITIAL_ID",
rewarded: __DEV__
? TestIds.REWARDED
: "ca-app-pub-xxxxxxxxxxxxxxxx/REWARDED_ID",
appOpen: __DEV__
? TestIds.APP_OPEN
: "ca-app-pub-xxxxxxxxxxxxxxxx/APP_OPEN_ID",
native: __DEV__ ? TestIds.NATIVE : "ca-app-pub-xxxxxxxxxxxxxxxx/NATIVE_ID",
};
// ── Constants ────────────────────────────────────────────────
const APP_OPEN_COOLDOWN_MS = 4 * 60 * 60 * 1000; // 4 hours
const INTERSTITIAL_COOLDOWN_MS = 3 * 60 * 1000; // 3 minutes
const INTERSTITIAL_DAILY_CAP = 3;
const LS_APP_OPEN_KEY = "ads_app_open_last_shown";
const LS_INTER_DATE_KEY = "ads_inter_last_date";
const LS_INTER_COUNT_KEY = "ads_inter_count_today";
const LS_INTER_TS_KEY = "ads_inter_last_ts";
function todayDateString() {
return new Date().toISOString().slice(0, 10);
}
// ── Context ──────────────────────────────────────────────────
interface AdsContextValue {
shouldShowAds: boolean;
bannerAdUnitId: string;
nativeAdUnitId: string;
showInterstitial: () => void;
showRewarded: () => Promise<boolean>;
}
const AdsContext = createContext<AdsContextValue>({
shouldShowAds: true,
bannerAdUnitId: AD_UNITS.banner,
nativeAdUnitId: AD_UNITS.native,
showInterstitial: () => {},
showRewarded: async () => false,
});
export function AdsProvider({ children }: { children: React.ReactNode }) {
const { isPremium } = usePurchases();
const shouldShowAds = !isPremium;
// ── App Open ─────────────────────────────────────────────
const appOpenAdRef = useRef<AppOpenAd | null>(null);
const appOpenLoadedRef = useRef(false);
const isFirstLaunchRef = useRef(true);
const loadAppOpen = useCallback(() => {
if (!shouldShowAds) return;
const ad = AppOpenAd.createForAdRequest(AD_UNITS.appOpen, {
requestNonPersonalizedAdsOnly: true,
});
ad.addEventHandler(AdEventType.LOADED, () => {
appOpenLoadedRef.current = true;
});
ad.addEventHandler(AdEventType.CLOSED, () => {
appOpenLoadedRef.current = false;
appOpenAdRef.current = null;
loadAppOpen();
});
ad.addEventHandler(AdEventType.ERROR, () => {
appOpenLoadedRef.current = false;
setTimeout(loadAppOpen, 30_000);
});
ad.load();
appOpenAdRef.current = ad;
}, [shouldShowAds]);
const tryShowAppOpen = useCallback(() => {
if (!shouldShowAds || !appOpenLoadedRef.current || !appOpenAdRef.current)
return;
// Skip on first cold launch
if (isFirstLaunchRef.current) {
isFirstLaunchRef.current = false;
return;
}
const lastShown = globalThis.localStorage.getItem(LS_APP_OPEN_KEY);
const now = Date.now();
if (lastShown && now - parseInt(lastShown, 10) < APP_OPEN_COOLDOWN_MS)
return;
globalThis.localStorage.setItem(LS_APP_OPEN_KEY, String(now));
appOpenAdRef.current.show().catch(() => loadAppOpen());
}, [shouldShowAds, loadAppOpen]);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
useEffect(() => {
if (!shouldShowAds) return;
loadAppOpen();
const sub = AppState.addEventListener("change", (state) => {
if (appStateRef.current !== "active" && state === "active") {
tryShowAppOpen();
}
appStateRef.current = state;
});
return () => sub.remove();
}, [shouldShowAds, loadAppOpen, tryShowAppOpen]);
// ── Interstitial ──────────────────────────────────────────
const interstitialRef = useRef<InterstitialAd | null>(null);
const interstitialLoadedRef = useRef(false);
const loadInterstitial = useCallback(() => {
if (!shouldShowAds) return;
const ad = InterstitialAd.createForAdRequest(AD_UNITS.interstitial, {
requestNonPersonalizedAdsOnly: true,
});
ad.addEventHandler(AdEventType.LOADED, () => {
interstitialLoadedRef.current = true;
});
ad.addEventHandler(AdEventType.CLOSED, () => {
interstitialLoadedRef.current = false;
interstitialRef.current = null;
loadInterstitial();
});
ad.addEventHandler(AdEventType.ERROR, () => {
interstitialLoadedRef.current = false;
});
ad.load();
interstitialRef.current = ad;
}, [shouldShowAds]);
useEffect(() => {
if (shouldShowAds) loadInterstitial();
}, [shouldShowAds, loadInterstitial]);
const showInterstitial = useCallback(() => {
if (
!shouldShowAds ||
!interstitialLoadedRef.current ||
!interstitialRef.current
)
return;
const now = Date.now();
const today = todayDateString();
const lastDate = globalThis.localStorage.getItem(LS_INTER_DATE_KEY);
let countToday = parseInt(
globalThis.localStorage.getItem(LS_INTER_COUNT_KEY) ?? "0",
10,
);
if (lastDate !== today) {
countToday = 0;
globalThis.localStorage.setItem(LS_INTER_DATE_KEY, today);
}
if (countToday >= INTERSTITIAL_DAILY_CAP) return;
const lastTs = parseInt(
globalThis.localStorage.getItem(LS_INTER_TS_KEY) ?? "0",
10,
);
if (now - lastTs < INTERSTITIAL_COOLDOWN_MS) return;
globalThis.localStorage.setItem(LS_INTER_TS_KEY, String(now));
globalThis.localStorage.setItem(LS_INTER_COUNT_KEY, String(countToday + 1));
interstitialRef.current.show().catch(() => loadInterstitial());
}, [shouldShowAds, loadInterstitial]);
// ── Rewarded ──────────────────────────────────────────────
const rewardedRef = useRef<RewardedAd | null>(null);
const rewardedLoadedRef = useRef(false);
const loadRewarded = useCallback(() => {
if (!shouldShowAds) return;
const ad = RewardedAd.createForAdRequest(AD_UNITS.rewarded, {
requestNonPersonalizedAdsOnly: true,
});
ad.addEventHandler(RewardedAdEventType.LOADED, () => {
rewardedLoadedRef.current = true;
});
ad.addEventHandler(AdEventType.CLOSED, () => {
rewardedLoadedRef.current = false;
rewardedRef.current = null;
loadRewarded();
});
ad.addEventHandler(AdEventType.ERROR, () => {
rewardedLoadedRef.current = false;
});
ad.load();
rewardedRef.current = ad;
}, [shouldShowAds]);
useEffect(() => {
if (shouldShowAds) loadRewarded();
}, [shouldShowAds, loadRewarded]);
const showRewarded = useCallback((): Promise<boolean> => {
return new Promise((resolve) => {
if (
!shouldShowAds ||
!rewardedLoadedRef.current ||
!rewardedRef.current
) {
resolve(false);
return;
}
const ad = rewardedRef.current!;
let rewarded = false;
ad.addEventHandler(RewardedAdEventType.EARNED_REWARD, () => {
rewarded = true;
});
ad.addEventHandler(AdEventType.CLOSED, () => {
resolve(rewarded);
});
ad.show().catch(() => resolve(false));
});
}, [shouldShowAds]);
return (
<AdsContext.Provider
value={{
shouldShowAds,
bannerAdUnitId: AD_UNITS.banner,
nativeAdUnitId: AD_UNITS.native,
showInterstitial,
showRewarded,
}}
>
{children}
</AdsContext.Provider>
);
}
export function useAds() {
return useContext(AdsContext);
}
Banner Ad (Tab Layout)
Place the banner below NativeTabs in src/app/(tabs)/_layout.tsx:
import { View, StyleSheet } from "react-native";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useTranslation } from "react-i18next";
import { BannerAd, BannerAdSize } from "react-native-google-mobile-ads";
import { useAds } from "@/context/ads-context";
export default function TabLayout() {
const { t } = useTranslation();
const { shouldShowAds, bannerAdUnitId } = useAds();
return (
<View style={styles.container}>
<NativeTabs>
<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>
{shouldShowAds && (
<View style={styles.adContainer}>
<BannerAd
unitId={bannerAdUnitId}
size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER}
requestOptions={{ requestNonPersonalizedAdsOnly: true }}
/>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
adContainer: { alignItems: "center", paddingBottom: 10 },
});
App Open Ad
AdsProvider handles App Open automatically via AppState listener. No extra setup is needed in screens.
First cold launch → NO App Open (avoids jarring first impression)
foreground return → App Open shown only if ≥ 4 hours since last shown
- The 4-hour timestamp is stored in
localStorageunderads_app_open_last_shown isFirstLaunchRefensures the ad never fires on the initial cold open- After
AdsProvidermounts, the App Open ad is preloaded silently and auto-reloaded after each show
Interstitial Usage Pattern
Call showInterstitial() from useAds() after a meaningful user action. Cooldown (3 min) and daily cap (3/day) are enforced automatically — just call it freely at good breakpoints.
import { useAds } from "@/context/ads-context";
function SomeScreen() {
const { showInterstitial } = useAds();
const handleActionComplete = async () => {
await doSomething();
showInterstitial(); // fire-and-forget, respects cooldown + cap
};
}
Good trigger points: after completing a level / generating content / sharing a result
Avoid: on screen mount, during navigation, mid-form, or on back press
Native Ad (In-Feed)
Create src/components/ads/NativeAdCard.tsx:
import { View, Text, StyleSheet } from "react-native";
import {
NativeAd,
NativeAdView,
HeadlineView,
BodyView,
CallToActionView,
AdvertiserView,
} from "react-native-google-mobile-ads";
import { useEffect, useState } from "react";
import { useAds } from "@/context/ads-context";
export function NativeAdCard() {
const { nativeAdUnitId, shouldShowAds } = useAds();
const [nativeAd, setNativeAd] = useState<NativeAd | null>(null);
useEffect(() => {
if (!shouldShowAds) return;
const ad = new NativeAd(nativeAdUnitId);
ad.load()
.then(() => setNativeAd(ad))
.catch(() => {});
return () => ad.destroy();
}, [shouldShowAds, nativeAdUnitId]);
if (!nativeAd || !shouldShowAds) return null;
return (
<NativeAdView nativeAd={nativeAd} style={styles.container}>
<View style={styles.badge}>
<Text style={styles.badgeText}>Ad</Text>
</View>
<AdvertiserView style={styles.advertiser} />
<HeadlineView style={styles.headline} />
<BodyView style={styles.body} />
<CallToActionView style={styles.cta} />
</NativeAdView>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: "rgba(255,255,255,0.05)",
borderRadius: 12,
padding: 14,
marginHorizontal: 16,
marginVertical: 4,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.08)",
},
badge: {
alignSelf: "flex-start",
backgroundColor: "#F59E0B",
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
marginBottom: 6,
},
badgeText: { color: "#000", fontSize: 10, fontWeight: "700" },
advertiser: { color: "rgba(255,255,255,0.4)", fontSize: 11 },
headline: {
color: "#FFFFFF",
fontSize: 15,
fontWeight: "700",
marginVertical: 4,
},
body: { color: "rgba(255,255,255,0.65)", fontSize: 13 },
cta: {
marginTop: 10,
backgroundColor: "#2563EB",
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 8,
alignSelf: "flex-start",
overflow: "hidden",
},
});
Inject into FlatList every 5 items:
import { NativeAdCard } from "@/components/ads/NativeAdCard";
import { useAds } from "@/context/ads-context";
import { useMemo } from "react";
const NATIVE_AD_INTERVAL = 5;
function MyListScreen() {
const { shouldShowAds } = useAds();
const listData = useMemo(() => {
if (!shouldShowAds)
return items.map((item) => ({ type: "item" as const, item }));
return items.flatMap((item, i) => {
const result: any[] = [{ type: "item", item }];
if ((i + 1) % NATIVE_AD_INTERVAL === 0) {
result.push({ type: "native_ad", key: `ad_${i}` });
}
return result;
});
}, [items, shouldShowAds]);
return (
<FlatList
data={listData}
keyExtractor={(entry) =>
entry.type === "item" ? entry.item.id : entry.key
}
renderItem={({ item: entry }) =>
entry.type === "native_ad" ? (
<NativeAdCard />
) : (
<MyItemComponent item={entry.item} />
)
}
/>
);
}
Rewarded Ad Usage Pattern
import { useAds } from "@/context/ads-context";
function SomeScreen() {
const { showRewarded } = useAds();
const handleWatchAd = async () => {
const earned = await showRewarded();
if (earned) {
unlockPremiumContent(); // grant the reward
}
};
}
Good use-cases: skip a waiting period, unlock a single feature temporarily, grant extra credits/attempts
Ad Unit ID Configuration
Replace the placeholder IDs in AD_UNITS inside src/context/ads-context.tsx:
| Format | Constant | AdMob Console Location |
|---|---|---|
| Banner | AD_UNITS.banner |
Apps → Ad units → Banner |
| Interstitial | AD_UNITS.interstitial |
Apps → Ad units → Interstitial |
| Rewarded | AD_UNITS.rewarded |
Apps → Ad units → Rewarded |
| App Open | AD_UNITS.appOpen |
Apps → Ad units → App open |
| Native | AD_UNITS.native |
Apps → Ad units → Native advanced |
- ALWAYS use
TestIds.*in__DEV__to avoid policy violations shouldShowAds = !isPremium— all formats hidden for premium usersAdsProvidermust be nested insidePurchasesProvider
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
- ❌
SafeAreaViewfromreact-native- Useimport { SafeAreaView } from 'react-native-safe-area-context'instead
Reanimated Usage (IMPORTANT)
NEVER call useAnimatedStyle, useSharedValue, or other reanimated hooks inside callbacks, loops, or conditions.
❌ WRONG:
const renderItem = () => {
const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 })); // ERROR!
return <Animated.View style={animatedStyle} />;
};
✅ CORRECT:
function MyComponent() {
const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 })); // Top level
return <Animated.View style={animatedStyle} />;
}
For lists, create a separate component for each item:
function 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:
rm 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:
grep -r "lineHeight" src/
Replace with padding or margin instead.
AFTER BUILDING A SCREEN (ALWAYS DO)
For EVERY screen you create or modify, you MUST also create or update the corresponding Maestro test flow in .maestro/:
| Screen | Flow file |
|---|---|
src/app/att-permission.tsx |
.maestro/01_att_permission.yaml |
src/app/onboarding.tsx |
.maestro/02_onboarding.yaml |
src/app/paywall.tsx |
.maestro/03_paywall_skip.yaml + .maestro/04_paywall_subscribe.yaml |
src/app/(tabs)/index.tsx |
.maestro/05_main_tabs.yaml |
src/app/settings.tsx |
.maestro/06_settings.yaml |
| Any new tab/screen | .maestro/0N_<screen_name>.yaml |
When creating a new project, also create the GitHub Actions workflows:
| File | Purpose |
|---|---|
.github/workflows/maestro-android.yml |
Android emulator E2E (ubuntu) |
.github/workflows/maestro-ios.yml |
iOS simulator E2E (macos runner) |
Always add testID props to key interactive elements:
<TouchableOpacity testID="skip-button"
<TouchableOpacity testID="close-button"
<TouchableOpacity testID="subscribe-button"
<TouchableOpacity testID="get-started-button"
Never skip this step. Screen code and its Maestro flow are delivered together.
AFTER COMPLETING CODE (ALWAYS RUN)
When you finish writing/modifying code, you MUST run these commands in order:
npx 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")
- SECOND ask: "Does the app require user login/authentication (OIDC)?"
- If YES → follow the Authentication (OIDC) section after project setup
- If NO → skip auth entirely
- Create the project in the CURRENT directory using:
bunx create-expo -t default@next app-name
- Update
app.jsonwith the bundle ID:
{
"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: expo-iap (expo-iap)
- Advertisements: Google AdMob (react-native-google-mobile-ads)
- Notifications: expo-notifications
- Animations: react-native-reanimated
- Storage: localStorage via expo-sqlite polyfill
- Authentication (optional): OIDC via expo-auth-session + expo-secure-store + zustand
WARNING: DO NOT USE AsyncStorage! Use expo-sqlite polyfill instead.
- Example usage
import "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
│ │ ├── onboarding.tsx
│ │ └── att-permission.tsx
│ ├── components/
│ │ ├── ui/
│ │ ├── themed-text.tsx
│ │ └── themed-view.tsx
│ ├── constants/
│ │ ├── theme.ts
│ │ └── [data-files].ts
│ ├── context/
│ │ ├── onboarding-context.tsx
│ │ ├── purchases-context.tsx
│ │ └── ads-context.tsx
│ ├── store/ # (if auth enabled)
│ │ ├── authStore.ts
│ │ └── useIntegratedAuth.ts
│ ├── hooks/
│ │ ├── use-notifications.ts
│ │ └── use-color-scheme.ts
│ ├── lib/
│ │ ├── notifications.ts
│ │ ├── purchases.ts
│ │ ├── ads.ts
│ │ └── i18n.ts
│ ├── services/ # (if auth enabled)
│ │ └── identity/
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── hooks/
│ └── locales/
│ ├── tr.json
│ └── en.json
├── .github/
│ └── workflows/
│ ├── maestro-android.yml # Android E2E (ubuntu, free)
│ └── maestro-ios.yml # iOS E2E (macos runner)
├── .maestro/
│ ├── 00_app_launch.yaml
│ ├── 01_att_permission.yaml
│ ├── 02_onboarding.yaml
│ ├── 03_paywall_skip.yaml
│ ├── 04_paywall_subscribe.yaml
│ ├── 05_main_tabs.yaml
│ ├── 06_settings.yaml
│ └── 07_full_flow.yaml
├── assets/
│ └── images/
├── ios/
├── android/
├── app.json
├── eas.json
├── package.json
└── tsconfig.json
Tab Navigation (NativeTabs)
Expo Router uses NativeTabs for native tab navigation:
import { 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
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
bun install
bun start
bun ios
bun android
bun lint
npx expo install --fix
npx expo prebuild --clean
EAS Build Commands
eas 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
Important Modules
expo-iap
- File:
src/context/purchases-context.tsx - Wraps
useIAPhook and checks subscription status on app startup - Product SKUs: weekly (
weekly_premium) and yearly (yearly_premium) - Paywall:
app/paywall.tsx - Exposes
usePurchases()→{ isPremium, loading, premiumExpiryDate, premiumProductId, refreshPremiumStatus } refreshPremiumStatus()must be called after a successful purchasedrainPendingTransactions()runs on startup to acknowledge stuck transactions- Use
getAvailablePurchases()for restore purchases flow - Always call
finishTransactionafter a successful purchase
PurchasesProvider Implementation (REQUIRED)
Create src/context/purchases-context.tsx:
import { finishTransaction, getAvailablePurchases, useIAP } from "expo-iap";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
// Replace these SKUs with the app's actual product IDs
const SUBSCRIPTION_SKUS = [
"com.company.appname.monthly",
"com.company.appname.yearly",
];
interface PurchasesContextValue {
isPremium: boolean;
loading: boolean;
premiumExpiryDate: Date | null;
premiumProductId: string | null;
refreshPremiumStatus: () => Promise<void>;
}
const PurchasesContext = createContext<PurchasesContextValue>({
isPremium: false,
loading: true,
premiumExpiryDate: null,
premiumProductId: null,
refreshPremiumStatus: async () => {},
});
export function PurchasesProvider({ children }: { children: React.ReactNode }) {
const { hasActiveSubscriptions } = useIAP();
const [isPremium, setIsPremium] = useState(false);
const [loading, setLoading] = useState(true);
const [premiumExpiryDate, setPremiumExpiryDate] = useState<Date | null>(null);
const [premiumProductId, setPremiumProductId] = useState<string | null>(null);
/** Acknowledge any transactions left unfinished (e.g. app killed mid-purchase). */
const drainPendingTransactions = async () => {
try {
const purchases = await getAvailablePurchases();
for (const purchase of purchases) {
try {
await finishTransaction({ purchase, isConsumable: false });
} catch {
// already acknowledged — safe to ignore
}
}
} catch {
// IAP unavailable (simulator, no network, etc.)
}
};
const refreshPremiumStatus = useCallback(async () => {
try {
await drainPendingTransactions();
const hasPremium = await hasActiveSubscriptions(SUBSCRIPTION_SKUS);
setIsPremium(hasPremium);
if (hasPremium) {
// Find the active subscription with the latest expiry date
const purchases = await getAvailablePurchases();
const activeSubs = purchases.filter((p) =>
SUBSCRIPTION_SKUS.includes(p.productId),
);
// Pick the one with the furthest expiry (expirationDateIOS is ms epoch, iOS only)
let bestExpiry: Date | null = null;
let bestProductId: string | null = null;
for (const p of activeSubs) {
const expMs = (p as { expirationDateIOS?: number | null })
.expirationDateIOS;
if (expMs) {
const d = new Date(expMs);
if (!bestExpiry || d > bestExpiry) {
bestExpiry = d;
bestProductId = p.productId;
…(truncated)