zafer-skills
Expo React Native mobile app development with RevenueCat payments, AdMob ads, i18n localization, onboarding flow, paywall, and NativeTabs navigation
git clone https://github.com/zaferayan/skills
git clone --depth=1 https://github.com/zaferayan/skills ~/.claude/skills/zaferayan-skills-zafer-skills
SKILL.mdExpo 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)
-
- Swipe-based onboarding with fullscreen background video and gradient overlaysrc/app/onboarding.tsx -
- RevenueCat paywall screen (shown after onboarding)src/app/paywall.tsx -
- Settings screen with language, theme, notifications, and reset onboarding optionssrc/app/settings.tsx
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
fromNativeTabs
for tab navigation - NEVER useexpo-router/unstable-native-tabs
or@react-navigation/bottom-tabs
from expo-routerTabs
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>;
Required Libraries (ALWAYS INSTALL)
Use
npx expo install to install libraries (NOT npm/yarn/bun install):
npx expo install react-native-purchases react-native-google-mobile-ads expo-notifications i18next react-i18next expo-localization react-native-reanimated expo-video expo-audio expo-sqlite expo-linear-gradient
Libraries:
(RevenueCat)react-native-purchases
(AdMob)react-native-google-mobile-adsexpo-notifications
+i18next
+react-i18nextexpo-localizationreact-native-reanimated
+expo-videoexpo-audio
(for localStorage)expo-sqlite
(for gradient overlays)expo-linear-gradient
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.
Banner Ad Implementation (REQUIRED)
You MUST implement banner ads in the Tab layout. Use this pattern:
import { View, StyleSheet } from 'react-native'; import { NativeTabs } from 'expo-router/unstable-native-tabs'; import { useTranslation } from 'react-i18next'; import { BannerAd, BannerAdSize, TestIds } from 'react-native-google-mobile-ads'; import { useAds } from '@/context/ads-context'; const adUnitId = __DEV__ ? TestIds.BANNER : 'ca-app-pub-xxxxxxxxxxxxxxxx/yyyyyyyyyy'; export default function TabLayout() { const { t } = useTranslation(); const { shouldShowAds } = 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={adUnitId} size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER} requestOptions={{ requestNonPersonalizedAdsOnly: true, }} /> </View> )} </View> ); } const styles = StyleSheet.create({ container: { flex: 1, }, adContainer: { alignItems: 'center', paddingBottom: 10, }, });
- ALWAYS use
in developmentTestIds.BANNER - Banner ad is placed below NativeTabs in the Tab layout
- Use
context to checkuseAds
(hides for premium users)shouldShowAds
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
insteadexpo-sqlite/localStorage/install - ❌ lineHeight style - Use padding/margin instead
- ❌
from expo-router - UseTabs
insteadNativeTabs - ❌
- Use@react-navigation/bottom-tabs
insteadNativeTabs - ❌
- Useexpo-av
for video,expo-video
for audio insteadexpo-audio - ❌
- Useexpo-ads-admob
insteadreact-native-google-mobile-ads - ❌ Any other ads library - ONLY use
react-native-google-mobile-ads - ❌ Reanimated hooks inside callbacks - Call at component top level
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
folder, DELETE(tabs)
to avoid route conflicts:src/app/index.tsx
rm src/app/index.tsx
- Check and remove
from these files:lineHeight
(comes with lineHeight by default - REMOVE IT)src/components/themed-text.tsx- 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
fixes dependency version mismatchesinstall --fix
recreates ios and android foldersprebuild --clean
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
- Update
with the bundle ID:app.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: RevenueCat (react-native-purchases)
- Advertisements: Google AdMob (react-native-google-mobile-ads)
- 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
! It causes layout issues in React Native. Use padding or margin instead.lineHeight
Project Structure
project-root/ ├── src/ │ ├── app/ │ │ ├── _layout.tsx │ │ ├── index.tsx │ │ ├── explore.tsx │ │ ├── settings.tsx │ │ ├── paywall.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 │ │ ├── ads.ts │ │ └── i18n.ts │ └── locales/ │ ├── tr.json │ └── en.json ├── 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
RevenueCat
- File:
lib/purchases.ts - Used for premium access
- Paywall:
app/paywall.tsx
AdMob
- File:
src/lib/ads.ts - Ads disabled for premium users
- Test IDs must be used in development
Notifications
- Files:
,src/lib/notifications.tssrc/hooks/use-notifications.ts - iOS requires push notification entitlement
Onboarding & Paywall Flow (CRITICAL)
- Files:
,src/app/onboarding.tsxsrc/app/paywall.tsx - Swipe-based screens with fullscreen background video
- Gradient overlay on video
- IMPORTANT: Paywall MUST appear immediately after onboarding completes
// In onboarding.tsx - when user completes onboarding: const handleComplete = async () => { await setOnboardingCompleted(true); router.replace('/paywall'); // Navigate to paywall immediately };
// In paywall.tsx - after purchase or skip: const handleContinue = () => { router.replace('/(tabs)'); // Navigate to main app };
Flow:
Onboarding → Paywall → Main App (tabs)
Paywall Subscription Options (REQUIRED)
Paywall MUST have two subscription options:
- Weekly - Default option
- Yearly - With "50% OFF" badge (recommended, should be highlighted)
// Subscription option component example: const subscriptionOptions = [ { id: 'weekly', title: t('paywall.weekly'), price: '$4.99/week', selected: selectedPlan === 'weekly', }, { id: 'yearly', title: t('paywall.yearly'), price: '$129.99/year', badge: '50% OFF', selected: selectedPlan === 'yearly', }, ]; // Yearly option should be visually highlighted as the best value
- Yearly option should show the discount badge prominently
- Default selection can be weekly, but yearly should be visually recommended
- Use RevenueCat package identifiers to match these options
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)
- Reset Onboarding - Restart onboarding flow (for testing/demo)
const { isPremium } = usePurchases(); // 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:
lib/i18n.ts - Languages stored in
locales/ - App restarts on language change
Coding Standards
- Use functional components
- Strict TypeScript
- Avoid hardcoded strings
- 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 - Enable new architecture via
newArchEnabled: true - Enable typed routes via
experiments.typedRoutes
App Store & Play Store Notes
- iOS ATT permission required
- Restore purchases must work correctly
- Target SDK must be up to date
Testing Checklist
- UI tested in all languages
- Dark / Light mode
- Notifications
- Premium flow
- Restore purchases
- Offline support
- Multiple screen sizes
After Development
npx expo prebuild --clean bun ios bun android
NOTE:
recreates ios and android folders. Run it after modifying native modules or app.json.prebuild --clean