zafer-skills
Expo React Native mobile app development with StoreKit 2 subscriptions (expo-iap) plus RevenueCat observer mode, AdMob ads with UMP/ATT consent, PostHog analytics, i18n with RTL, onboarding flow, paywall, and NativeTabs navigation
The English summary is being prepared. Showing the original description.
Run score Not tested
See how to installFiles included (2)
- SKILL.md33.8 KB
- README.md2.1 KB
SKILL.md (original)
Review the instructions the agent will receive before you install.
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:
import { 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)
import { 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):
npx 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:
{
"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):
[
"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:
// 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 },
});
// 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.
// 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:
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 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")
- Create the project in the CURRENT directory using:
bunx 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:
{
"expo": {
"experiments": { "typedRoutes": true, "reactCompiler": true },
"ios": {
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"CFBundleAllowMixedLocalizations": true
}
}
}
}
- 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: 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
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
│ │ ├── 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:
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
- 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
bun install
bun start
bun ios
bun android
bun lint
npx expo install --fix
npx expo prebuild --clean
EAS Build Commands
eas.json baseline:
{
"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>" } } }
}
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
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
// 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
useEffect(() => {
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)
const [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)
const { 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:
I18nManager.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
const 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
// 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
<ThemeProvider>
<OnboardingProvider>
<AdsProvider>
<Stack />
</AdsProvider>
</OnboardingProvider>
</ThemeProvider>
useColorScheme Hook
File: src/hooks/use-color-scheme.ts
import { 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
npx expo prebuild --clean
bun ios
bun android
NOTE:
prebuild --cleanrecreates ios and android folders. Run it after modifying native modules or app.json.
Reviews
No reviews yet. Tell others what you thought.
Comments
No login needed. Share how it worked for you, ask a question, or note where it did not run.
No comments yet. Be the first to write one.