Skill v1.0.1
currentAutomated scan100/1003 files
version: "1.0.1"
Expo Standards
Purpose: Expo-specific patterns and best practices for App Factory mobile applications.
When to Activate
This skill activates during:
- Milestone 1 (Project Scaffold) - During initial setup
- Throughout Build - As reference for Expo patterns
Rule Categories
| Category | Priority | |
|---|---|---|
| Expo Router | HIGH | |
| Configuration | MEDIUM | |
| Assets | MEDIUM | |
| Native Modules | LOW |
Expo Router (HIGH)
R1: File-Based Routing Structure
Use Expo Router v4 file conventions correctly.
Correct Structure:
app/├── _layout.tsx # Root layout (navigation structure)├── index.tsx # Home screen (/)├── (tabs)/ # Tab group│ ├── _layout.tsx # Tab navigator layout│ ├── index.tsx # First tab│ ├── explore.tsx # Second tab│ └── settings.tsx # Third tab├── (auth)/ # Auth group (shared layout)│ ├── _layout.tsx│ ├── login.tsx│ └── register.tsx├── [id].tsx # Dynamic route (/123)├── item/│ └── [id].tsx # Nested dynamic (/item/123)└── +not-found.tsx # 404 screen
R2: Layout Structure
Root layout must wrap with required providers.
Incorrect:
// app/_layout.tsxexport default function RootLayout() {return <Stack />;}
Correct:
// app/_layout.tsximport { Stack } from 'expo-router';import { SafeAreaProvider } from 'react-native-safe-area-context';import { GestureHandlerRootView } from 'react-native-gesture-handler';export default function RootLayout() {return (<GestureHandlerRootView style={{ flex: 1 }}><SafeAreaProvider><Stack screenOptions={{ headerShown: false }} /></SafeAreaProvider></GestureHandlerRootView>);}
R3: Navigation with Type Safety
Use typed navigation for compile-time route checking.
Incorrect:
router.push('/item/123');router.push({ pathname: '/item/[id]', params: { id: '123' } });
Correct:
import { router } from 'expo-router';// Simple navigationrouter.push('/item/123');// With typed paramsrouter.push({pathname: '/item/[id]',params: { id: item.id },});// Replace (no back)router.replace('/home');// Go backrouter.back();
R4: Tab Navigator Setup
Configure tab navigator with icons and labels.
Correct:
// app/(tabs)/_layout.tsximport { Tabs } from 'expo-router';import { Home, Search, Settings } from 'lucide-react-native';export default function TabLayout() {return (<TabsscreenOptions={{tabBarActiveTintColor: '#007AFF',tabBarInactiveTintColor: '#999',headerShown: false,}}><Tabs.Screenname="index"options={{title: 'Home',tabBarIcon: ({ color, size }) => <Home color={color} size={size} />,}}/><Tabs.Screenname="explore"options={{title: 'Explore',tabBarIcon: ({ color, size }) => <Search color={color} size={size} />,}}/><Tabs.Screenname="settings"options={{title: 'Settings',tabBarIcon: ({ color, size }) => <Settings color={color} size={size} />,}}/></Tabs>);}
R5: Deep Linking Configuration
Enable deep linking with proper scheme.
In app.config.js:
export default {expo: {scheme: 'myapp',// ...},};
Usage:
// Links that work: myapp://item/123<Link href="/item/123">View Item</Link>
Configuration (MEDIUM)
C1: app.config.js Structure
Use dynamic config for environment-based settings.
Correct:
// app.config.jsexport default ({ config }) => ({...config,name: process.env.APP_ENV === 'production' ? 'MyApp' : 'MyApp (Dev)',slug: 'myapp',version: '1.0.0',orientation: 'portrait',icon: './assets/icon.png',scheme: 'myapp',splash: {image: './assets/splash.png',resizeMode: 'contain',backgroundColor: '#ffffff',},ios: {supportsTablet: true,bundleIdentifier: 'com.company.myapp',},android: {adaptiveIcon: {foregroundImage: './assets/adaptive-icon.png',backgroundColor: '#ffffff',},package: 'com.company.myapp',},plugins: ['expo-router','expo-font',['expo-image-picker',{photosPermission: 'Allow $(PRODUCT_NAME) to access your photos.',},],],extra: {eas: {projectId: 'your-project-id',},},});
C2: Environment Variables
Use expo-constants for env vars, not process.env directly.
Incorrect:
const API_URL = process.env.API_URL;
Correct:
// app.config.jsexport default {extra: {apiUrl: process.env.API_URL || 'https://api.example.com',},};// In codeimport Constants from 'expo-constants';const API_URL = Constants.expoConfig?.extra?.apiUrl;
C3: EAS Build Configuration
Configure eas.json for build profiles.
Correct:
{"cli": {"version": ">= 5.0.0"},"build": {"development": {"developmentClient": true,"distribution": "internal"},"preview": {"distribution": "internal"},"production": {}},"submit": {"production": {}}}
Assets (MEDIUM)
A1: App Icon Requirements
Icon must be exactly 1024x1024 PNG, no transparency.
Requirements:
- Size: 1024x1024 pixels
- Format: PNG
- No transparency (solid background)
- No rounded corners (system applies them)
A2: Splash Screen Configuration
Configure splash with proper resize mode.
Correct:
// app.config.jssplash: {image: './assets/splash.png',resizeMode: 'contain', // or 'cover'backgroundColor: '#ffffff',},
Splash image requirements:
- Recommended: 1284x2778 (iPhone 14 Pro Max)
- Format: PNG
- Keep logo centered in safe area
A3: Adaptive Icons (Android)
Configure both foreground and background for Android.
Correct:
android: {adaptiveIcon: {foregroundImage: './assets/adaptive-icon.png', // 1024x1024, logo onlybackgroundColor: '#ffffff', // Or backgroundImage},},
A4: Font Loading
Load fonts before rendering app.
Correct:
// app/_layout.tsximport { useFonts } from 'expo-font';import * as SplashScreen from 'expo-splash-screen';import { useEffect } from 'react';SplashScreen.preventAutoHideAsync();export default function RootLayout() {const [fontsLoaded] = useFonts({'Inter-Regular': require('../assets/fonts/Inter-Regular.ttf'),'Inter-Bold': require('../assets/fonts/Inter-Bold.ttf'),});useEffect(() => {if (fontsLoaded) {SplashScreen.hideAsync();}}, [fontsLoaded]);if (!fontsLoaded) {return null;}return <Stack />;}
Native Modules (LOW)
N1: Expo SDK Preference
Prefer Expo SDK modules over bare React Native equivalents.
Incorrect:
import { CameraRoll } from '@react-native-camera-roll/camera-roll';
Correct:
import * as MediaLibrary from 'expo-media-library';
N2: Permission Handling
Request permissions using expo modules with proper error handling.
Incorrect:
const result = await ImagePicker.launchImageLibraryAsync();
Correct:
import * as ImagePicker from 'expo-image-picker';async function pickImage() {const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();if (status !== 'granted') {Alert.alert('Permission needed', 'Please grant photo library access to select images.', [{ text: 'Cancel', style: 'cancel' },{ text: 'Settings', onPress: () => Linking.openSettings() },]);return null;}const result = await ImagePicker.launchImageLibraryAsync({mediaTypes: ImagePicker.MediaTypeOptions.Images,allowsEditing: true,aspect: [1, 1],quality: 0.8,});if (!result.canceled) {return result.assets[0];}return null;}
N3: SQLite Setup
Use expo-sqlite with proper initialization.
Correct:
// src/data/database.tsimport * as SQLite from 'expo-sqlite';const db = SQLite.openDatabaseSync('app.db');export function initDatabase() {db.execSync(`CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY AUTOINCREMENT,title TEXT NOT NULL,created_at TEXT DEFAULT CURRENT_TIMESTAMP);`);}export function getItems() {return db.getAllSync<Item>('SELECT * FROM items ORDER BY created_at DESC');}export function addItem(title: string) {return db.runSync('INSERT INTO items (title) VALUES (?)', title);}
N4: Secure Storage
Use expo-secure-store for sensitive data.
Incorrect:
import AsyncStorage from '@react-native-async-storage/async-storage';await AsyncStorage.setItem('auth_token', token);
Correct:
import * as SecureStore from 'expo-secure-store';// Store securelyawait SecureStore.setItemAsync('auth_token', token);// Retrieveconst token = await SecureStore.getItemAsync('auth_token');// Deleteawait SecureStore.deleteItemAsync('auth_token');
RevenueCat Integration (REQUIRED)
RC1: SDK Initialization
Initialize RevenueCat early in app lifecycle.
Correct:
// src/lib/revenuecat/index.tsimport Purchases from 'react-native-purchases';import { Platform } from 'react-native';const API_KEYS = {ios: process.env.EXPO_PUBLIC_REVENUECAT_IOS_KEY || '',android: process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_KEY || '',};export async function initPurchases() {const apiKey = Platform.OS === 'ios' ? API_KEYS.ios : API_KEYS.android;if (!apiKey) {console.warn('RevenueCat API key not configured');return;}Purchases.configure({ apiKey });}
In \_layout.tsx:
useEffect(() => {initPurchases();}, []);
RC2: Offering Display
Fetch and display offerings properly.
Correct:
import Purchases from 'react-native-purchases';async function loadOfferings() {try {const offerings = await Purchases.getOfferings();if (offerings.current) {return offerings.current.availablePackages;}return [];} catch (error) {console.error('Failed to load offerings:', error);return [];}}
RC3: Purchase Flow
Handle purchases with proper error handling.
Correct:
async function purchasePackage(pkg: PurchasesPackage) {try {const { customerInfo } = await Purchases.purchasePackage(pkg);if (customerInfo.entitlements.active['premium']) {// Grant accessreturn { success: true };}} catch (error) {if (error.userCancelled) {return { success: false, cancelled: true };}throw error;}}
Version
- 1.0 (2026-01-15): Initial release