mobile-framework-expo
Expo managed workflow
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/mobile-framework-expo/skills/mobile-framework-expo
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Expo Development Patterns
Quick Guide: Build production-ready React Native apps with Expo. Use managed workflow with Continuous Native Generation for most projects, Expo Router for file-based navigation, and EAS for builds/updates. Development builds replace Expo Go for production testing.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST use development builds for production testing - Expo Go is for prototyping only)
(You MUST update runtimeVersion when making native dependency changes to prevent OTA update crashes)
(You MUST use config plugins for native customization - NEVER manually edit android/ios directories in managed workflow)
(You MUST use EXPO_PUBLIC_ prefix for client-side environment variables - NEVER store secrets in these variables)
</critical_requirements>
Auto-detection: Expo, expo-router, EAS Build, EAS Update, expo-dev-client, app.config.js, app.json, expo prebuild, npx expo, eas.json, expo-constants, expo-notifications, Continuous Native Generation, CNG
When to use:
- Starting new React Native projects with rapid development needs
- Building apps that need OTA (over-the-air) updates
- Using file-based routing with convention-over-configuration
- Managing native code without maintaining android/ios directories
- Deploying to app stores with cloud builds
Key patterns covered:
- Managed workflow with Continuous Native Generation (CNG)
- Expo Router file-based navigation
- EAS Build, Submit, and Update workflows
- Development builds vs Expo Go
- Config plugins for native customization
- Environment configuration and secrets
- Push notifications setup
When NOT to use:
- Apps requiring complex custom native code beyond Expo Modules API
- When app size must be under 15MB (Expo adds overhead)
- Legacy React Native projects not ready for migration
<red_flags>
RED FLAGS
- Expo Go for production testing -- missing native modules, push notifications, accurate splash screens. Always use development builds.
- Not updating runtimeVersion after native changes -- OTA updates crash on apps with incompatible native code. Use
"fingerprint"policy for automatic detection. - Storing secrets in
EXPO_PUBLIC_variables -- embedded in JS bundle, visible to anyone who decompiles. Use EAS Secrets and backend proxies. - Manually editing android/ios directories -- changes lost on
expo prebuild --clean. Use config plugins. - Destructuring
process.env-- Metro requires direct property access (process.env.EXPO_PUBLIC_*). Destructuring and bracket notation produceundefined. - Using
expo-av-- removed in SDK 55. Migrate toexpo-videoandexpo-audio. - Legacy Architecture -- removed after SDK 54. React Native 0.82+ requires New Architecture.
Full anti-patterns and gotchas: reference.md
</red_flags>
Detailed Resources:
- examples/core.md - Project config, environment variables, fonts, images
- examples/router.md - File-based routing, tabs, auth flows, modals
- examples/eas.md - Cloud builds, app store submission, OTA updates
- reference.md - Decision frameworks, SDK compatibility, anti-patterns
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST use development builds for production testing - Expo Go is for prototyping only)
(You MUST update runtimeVersion when making native dependency changes to prevent OTA update crashes)
(You MUST use config plugins for native customization - NEVER manually edit android/ios directories in managed workflow)
(You MUST use EXPO_PUBLIC_ prefix for client-side environment variables - NEVER store secrets in these variables)
Failure to follow these rules will cause OTA update crashes, broken builds, and security vulnerabilities.
</critical_reminders>
Files (skills)
-
examples
-
core.md 8.5 KB
# Core Expo Patterns > Essential configuration, environment, and asset patterns. See [SKILL.md](../SKILL.md) for decisions and philosophy. --- ## Dynamic Configuration (app.config.ts) Use `app.config.ts` for environment-aware builds with TypeScript support. Use named constants for SDK versions and build numbers -- never hardcode them. ```typescript // app.config.ts import type { ExpoConfig, ConfigContext } from "expo/config"; const APP_NAME = "MyApp"; const APP_SLUG = "my-app"; const APP_VERSION = "1.0.0"; const BUILD_NUMBER = 1; const IOS_DEPLOYMENT_TARGET = "15.1"; const ANDROID_COMPILE_SDK = 35; const ANDROID_TARGET_SDK = 35; const ANDROID_MIN_SDK = 24; const IS_PRODUCTION = process.env.APP_ENV === "production"; const IS_PREVIEW = process.env.APP_ENV === "preview"; function getAppName(): string { if (IS_PRODUCTION) return APP_NAME; if (IS_PREVIEW) return `${APP_NAME} (Preview)`; return `${APP_NAME} (Dev)`; } function getBundleIdentifier(): string { const base = "com.example.myapp"; if (IS_PRODUCTION) return base; if (IS_PREVIEW) return `${base}.preview`; return `${base}.dev`; } export default ({ config }: ConfigContext): ExpoConfig => ({ ...config, name: getAppName(), slug: APP_SLUG, version: APP_VERSION, orientation: "portrait", icon: "./assets/icon.png", userInterfaceStyle: "automatic", splash: { image: "./assets/splash-icon.png", resizeMode: "contain", backgroundColor: "#ffffff", }, ios: { supportsTablet: true, bundleIdentifier: getBundleIdentifier(), buildNumber: String(BUILD_NUMBER), config: { usesNonExemptEncryption: false, }, }, android: { adaptiveIcon: { foregroundImage: "./assets/adaptive-icon.png", backgroundColor: "#ffffff", }, package: getBundleIdentifier(), versionCode: BUILD_NUMBER, }, plugins: [ "expo-router", [ "expo-build-properties", { android: { compileSdkVersion: ANDROID_COMPILE_SDK, targetSdkVersion: ANDROID_TARGET_SDK, minSdkVersion: ANDROID_MIN_SDK, }, ios: { deploymentTarget: IOS_DEPLOYMENT_TARGET, }, }, ], ], extra: { eas: { projectId: process.env.EAS_PROJECT_ID, }, environment: process.env.APP_ENV || "development", }, updates: { url: `https://u.expo.dev/${process.env.EAS_PROJECT_ID}`, }, runtimeVersion: { policy: "appVersion", }, }); ``` **Why good:** Named constants, environment-specific bundle identifiers prevent app store conflicts, `usesNonExemptEncryption: false` avoids iOS compliance review delay, `runtimeVersion` policy enables safe OTA updates --- ## Config Plugins Config plugins modify native code declaratively. Changes survive `expo prebuild --clean`. ### Camera and Permissions ```typescript // app.config.ts plugins array plugins: [ [ "expo-camera", { cameraPermission: "Allow $(PRODUCT_NAME) to access your camera.", microphonePermission: "Allow $(PRODUCT_NAME) to access your microphone.", recordAudioAndroid: true, }, ], ]; ``` ### Location Services ```typescript plugins: [ [ "expo-location", { locationAlwaysAndWhenInUsePermission: "Allow $(PRODUCT_NAME) to use your location for navigation.", locationAlwaysPermission: "Allow $(PRODUCT_NAME) to use your location in the background.", locationWhenInUsePermission: "Allow $(PRODUCT_NAME) to use your location while using the app.", isAndroidBackgroundLocationEnabled: true, isAndroidForegroundServiceEnabled: true, }, ], ]; ``` ### Notifications ```typescript plugins: [ [ "expo-notifications", { icon: "./assets/notification-icon.png", color: "#ffffff", sounds: ["./assets/sounds/notification.wav"], mode: "production", }, ], ]; ``` ### Build Properties ```typescript const IOS_DEPLOYMENT_TARGET = "15.1"; const ANDROID_COMPILE_SDK = 35; const ANDROID_TARGET_SDK = 35; const ANDROID_MIN_SDK = 24; const KOTLIN_VERSION = "1.9.24"; plugins: [ [ "expo-build-properties", { android: { compileSdkVersion: ANDROID_COMPILE_SDK, targetSdkVersion: ANDROID_TARGET_SDK, minSdkVersion: ANDROID_MIN_SDK, kotlinVersion: KOTLIN_VERSION, enableProguardInReleaseBuilds: true, }, ios: { deploymentTarget: IOS_DEPLOYMENT_TARGET, useFrameworks: "static", }, }, ], ]; ``` --- ## Environment Variables ### Setup ```bash # .env (committed - default values) EXPO_PUBLIC_API_URL=https://api.example.com EXPO_PUBLIC_APP_ENV=development # .env.local (gitignored - local overrides) EXPO_PUBLIC_API_URL=http://localhost:3000 # .env.production (committed - production values) EXPO_PUBLIC_API_URL=https://api.example.com EXPO_PUBLIC_APP_ENV=production ``` ### Type-Safe Access ```typescript // config/env.ts const API_URL = process.env.EXPO_PUBLIC_API_URL; const APP_ENV = process.env.EXPO_PUBLIC_APP_ENV; // IMPORTANT: Metro requires direct property access // These patterns DON'T work: // const { EXPO_PUBLIC_API_URL } = process.env; // BAD - undefined // process.env['EXPO_PUBLIC_API_URL']; // BAD - undefined // Object.keys(process.env).filter(...) // BAD - won't include EXPO_PUBLIC_* if (!API_URL) { throw new Error("EXPO_PUBLIC_API_URL environment variable is required"); } export const env = { apiUrl: API_URL, appEnv: APP_ENV ?? "development", isProduction: APP_ENV === "production", isDevelopment: APP_ENV === "development" || !APP_ENV, } as const; export type Environment = typeof env; ``` ### Using Constants for Runtime Config ```typescript // hooks/use-config.ts import Constants from "expo-constants"; interface AppConfig { apiUrl: string; environment: string; projectId: string | undefined; } export function useConfig(): AppConfig { const extra = Constants.expoConfig?.extra; return { apiUrl: process.env.EXPO_PUBLIC_API_URL ?? "https://api.example.com", environment: extra?.environment ?? "development", projectId: extra?.eas?.projectId, }; } ``` --- ## Font Loading ### Basic Font Loading with Splash Screen ```typescript // app/_layout.tsx import { useFonts } from "expo-font"; import * as SplashScreen from "expo-splash-screen"; import { useEffect } from "react"; import { Stack } from "expo-router"; // Prevent auto-hide before fonts load SplashScreen.preventAutoHideAsync(); export default function RootLayout() { const [fontsLoaded, fontError] = useFonts({ "Inter-Regular": require("../assets/fonts/Inter-Regular.ttf"), "Inter-Medium": require("../assets/fonts/Inter-Medium.ttf"), "Inter-SemiBold": require("../assets/fonts/Inter-SemiBold.ttf"), "Inter-Bold": require("../assets/fonts/Inter-Bold.ttf"), }); useEffect(() => { if (fontsLoaded || fontError) { SplashScreen.hideAsync(); } }, [fontsLoaded, fontError]); if (!fontsLoaded && !fontError) { return null; } return <Stack />; } ``` ### Config Plugin Font Loading (Recommended for Production) Pre-bundle fonts at build time to avoid runtime loading delay: ```json { "expo": { "plugins": [ [ "expo-font", { "fonts": [ "./assets/fonts/Inter-Regular.ttf", "./assets/fonts/Inter-Medium.ttf", "./assets/fonts/Inter-Bold.ttf" ] } ] ] } } ``` --- ## Image Handling ### Optimized Images with expo-image ```typescript // components/optimized-image.tsx import { Image, type ImageProps } from "expo-image"; const BLUR_HASH = "L6PZfSi_.AyE_3t7t7R**0o#DgR4"; const IMAGE_TRANSITION_MS = 200; interface OptimizedImageProps extends Omit<ImageProps, "source"> { uri: string; width: number; height: number; blurHash?: string; } export function OptimizedImage({ uri, width, height, blurHash = BLUR_HASH, style, ...props }: OptimizedImageProps) { return ( <Image source={{ uri }} placeholder={blurHash} contentFit="cover" transition={IMAGE_TRANSITION_MS} cachePolicy="memory-disk" style={[{ width, height }, style]} {...props} /> ); } ``` **Why good:** Blur hash placeholder prevents layout shift, `memory-disk` caching avoids re-downloads, transition provides smooth loading UX ### Local Images ```typescript import { Image } from "expo-image"; // Static import - bundled at build time const logoSource = require("../assets/images/logo.png"); export function Logo() { return ( <Image source={logoSource} contentFit="contain" style={{ width: 120, height: 40 }} /> ); } ``` -
eas.md 11.5 KB
# EAS (Expo Application Services) Patterns > Cloud build, submit, and OTA update workflows. See [SKILL.md](../SKILL.md) for decisions and philosophy. --- ## eas.json Configuration ### Basic Configuration ```json { "cli": { "version": ">= 7.0.0" }, "build": { "development": { "developmentClient": true, "distribution": "internal", "ios": { "simulator": true }, "android": { "buildType": "apk" } }, "preview": { "distribution": "internal", "channel": "preview" }, "production": { "channel": "production" } }, "submit": { "production": { "ios": { "appleId": "your@email.com", "ascAppId": "1234567890" }, "android": { "serviceAccountKeyPath": "./google-services.json", "track": "internal" } } } } ``` ### Complete Configuration with All Profiles ```json { "cli": { "version": ">= 7.0.0", "appVersionSource": "remote" }, "build": { "base": { "node": "20.17.0", "env": { "EXPO_PUBLIC_APP_ENV": "development" } }, "development": { "extends": "base", "developmentClient": true, "distribution": "internal", "ios": { "simulator": true, "resourceClass": "m-medium" }, "android": { "buildType": "apk" } }, "development-device": { "extends": "development", "ios": { "simulator": false } }, "preview": { "extends": "base", "distribution": "internal", "channel": "preview", "env": { "EXPO_PUBLIC_APP_ENV": "preview" } }, "production": { "extends": "base", "autoIncrement": "version", "channel": "production", "env": { "EXPO_PUBLIC_APP_ENV": "production" }, "ios": { "resourceClass": "m-medium" }, "android": { "buildType": "app-bundle" } } }, "submit": { "production": { "ios": { "appleId": "your@email.com", "ascAppId": "1234567890", "appleTeamId": "ABC123DEF" }, "android": { "serviceAccountKeyPath": "./google-services.json", "track": "internal", "releaseStatus": "draft" } } } } ``` --- ## Development Builds ### Create Development Build ```bash # iOS Simulator eas build --profile development --platform ios # iOS Device (requires Apple Developer account) eas build --profile development-device --platform ios # Android APK eas build --profile development --platform android # Both platforms eas build --profile development --platform all ``` ### Local Development Build ```bash # Requires android/ios directories (run prebuild first) npx expo prebuild # Build locally npx expo run:ios npx expo run:android # Build locally with specific device npx expo run:ios --device "iPhone 15 Pro" ``` ### Install Development Build ```bash # List available builds eas build:list # Install on simulator/emulator (after build completes) eas build:run --platform ios eas build:run --platform android # Install specific build eas build:run --id [build-id] ``` --- ## Preview Builds ```bash # Create preview build eas build --profile preview --platform ios eas build --profile preview --platform android # Internal distribution - generates QR code # Testers scan to install from Expo dashboard ``` ### Internal Distribution Setup (iOS) 1. Create Apple Developer account with Ad Hoc distribution 2. Register test devices in Apple Developer Portal 3. Add devices to EAS: ```bash # Register devices eas device:create # List registered devices eas device:list ``` --- ## Production Builds ```bash # Create production build eas build --profile production --platform ios eas build --profile production --platform android # Create both platforms eas build --profile production --platform all # Build with auto-increment version eas build --profile production --platform all --auto-submit ``` ### Production Build Configuration ```json { "build": { "production": { "autoIncrement": "version", "channel": "production", "ios": { "resourceClass": "m-medium" }, "android": { "buildType": "app-bundle", "gradleCommand": ":app:bundleRelease" } } } } ``` --- ## App Store Submission ### iOS App Store ```bash # Submit latest production build eas submit --platform ios # Submit specific build eas submit --platform ios --id [build-id] # Build and submit in one command eas build --profile production --platform ios --auto-submit ``` ### iOS Credentials Setup ```bash # Manage iOS credentials eas credentials --platform ios # Options: # - Let EAS manage (recommended for most) # - Use own certificates (enterprise/specific requirements) ``` ### Google Play Store ```bash # Submit to internal testing track eas submit --platform android # Submit specific build eas submit --platform android --id [build-id] ``` ```json { "submit": { "production": { "android": { "serviceAccountKeyPath": "./google-services.json", "track": "internal", "releaseStatus": "draft" } } } } ``` ### Google Play Setup 1. Create Service Account in Google Cloud Console 2. Grant access to Play Console 3. Download JSON key file 4. Add path to eas.json --- ## OTA Updates (EAS Update) ### Update Configuration ```typescript // app.config.ts export default { updates: { url: `https://u.expo.dev/${process.env.EAS_PROJECT_ID}`, fallbackToCacheTimeout: 0, }, runtimeVersion: { policy: "appVersion", // or "sdkVersion", "nativeVersion", "fingerprint" }, // Alternative: exact runtimeVersion // runtimeVersion: "1.0.0", }; ``` ### Runtime Version Policies | Policy | When to Use | Auto Updates | | --------------- | ----------------------------------- | -------------------------- | | `appVersion` | Standard apps, tracks version field | Within same app version | | `sdkVersion` | SDK-based versioning | Within same SDK | | `nativeVersion` | iOS buildNumber/Android versionCode | Within same native version | | `fingerprint` | Automatic detection | Detects native changes | | Explicit string | Full control | Only matching versions | ### Publish Updates ```bash # SDK 55+ uses --environment (replaces --channel) eas update --environment preview --message "Bug fix for login flow" eas update --environment production --message "Version 1.2.0 release" # SDK 54 and earlier uses --channel eas update --channel preview --message "Bug fix for login flow" eas update --channel production --message "Version 1.2.0 release" ``` ### Update Workflow ```typescript // hooks/use-updates.ts import * as Updates from "expo-updates"; import { useEffect, useState } from "react"; import { Alert } from "react-native"; const UPDATE_CHECK_INTERVAL_MS = 30000; // 30 seconds interface UpdateState { isChecking: boolean; isAvailable: boolean; isDownloading: boolean; } export function useOTAUpdates() { const [state, setState] = useState<UpdateState>({ isChecking: false, isAvailable: false, isDownloading: false, }); const checkForUpdates = async () => { if (__DEV__) return; // Skip in development try { setState((prev) => ({ ...prev, isChecking: true })); const update = await Updates.checkForUpdateAsync(); if (update.isAvailable) { setState((prev) => ({ ...prev, isAvailable: true, isDownloading: true, })); await Updates.fetchUpdateAsync(); setState((prev) => ({ ...prev, isDownloading: false })); Alert.alert( "Update Ready", "A new version has been downloaded. Restart to apply.", [ { text: "Later", style: "cancel" }, { text: "Restart", onPress: () => Updates.reloadAsync(), }, ], ); } } catch (error) { console.error("Error checking for updates:", error); } finally { setState((prev) => ({ ...prev, isChecking: false })); } }; useEffect(() => { checkForUpdates(); // Check periodically const interval = setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL_MS); return () => clearInterval(interval); }, []); return { ...state, checkForUpdates, }; } ``` ### Update Channels Strategy ``` Channels: ├── production → App Store releases ├── preview → TestFlight / Internal testing └── development → Development builds Workflow: 1. Develop on development channel 2. Merge to staging → publish to preview channel 3. QA approval → publish to production channel ``` --- ## Environment Variables in EAS ### Build-Time Variables ```json { "build": { "preview": { "env": { "EXPO_PUBLIC_API_URL": "https://staging.api.example.com", "EXPO_PUBLIC_APP_ENV": "preview" } }, "production": { "env": { "EXPO_PUBLIC_API_URL": "https://api.example.com", "EXPO_PUBLIC_APP_ENV": "production" } } } } ``` ### Secrets (Sensitive Values) ```bash # Set secret for project eas secret:create --scope project --name SENTRY_AUTH_TOKEN --value "your-token" # Set secret for account (shared across projects) eas secret:create --scope account --name GOOGLE_SERVICES_JSON --type file --value ./google-services.json # List secrets eas secret:list # Delete secret eas secret:delete --name SENTRY_AUTH_TOKEN ``` ### Using Secrets in Build ```json { "build": { "production": { "env": { "SENTRY_AUTH_TOKEN": "@sentry-auth-token" } } } } ``` --- ## Version Management ### Auto Version Increment ```json { "build": { "production": { "autoIncrement": "buildNumber" } } } ``` | Value | iOS | Android | When to Use | | ------------- | ------------------------ | ------------------------ | ------------ | | `buildNumber` | Increments `buildNumber` | Increments `versionCode` | Each build | | `version` | Increments `version` | Increments `versionName` | Each release | ### Syncing Versions ```typescript // app.config.ts const APP_VERSION = "1.2.0"; const BUILD_NUMBER = 42; export default { version: APP_VERSION, ios: { buildNumber: String(BUILD_NUMBER), }, android: { versionCode: BUILD_NUMBER, }, }; ``` ### Remote Version Source ```json { "cli": { "appVersionSource": "remote" } } ``` This uses EAS to track versions instead of local config. --- ## CI/CD Integration ### GitHub Actions Example ```yaml # .github/workflows/eas-build.yml name: EAS Build on: push: branches: [main] pull_request: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm ci - name: Setup EAS uses: expo/expo-github-action@v8 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} - name: Build Preview if: github.event_name == 'pull_request' run: eas build --profile preview --platform all --non-interactive - name: Build Production if: github.ref == 'refs/heads/main' run: eas build --profile production --platform all --non-interactive ``` > For complete CLI reference, see [reference.md](../reference.md) - CLI Commands Quick Reference section. -
router.md 17.4 KB
# Expo Router Patterns > File-based routing for React Native applications. See [SKILL.md](../SKILL.md) for decisions and philosophy. --- ## Route Notation Reference | Notation | Example | URL | Description | | --------- | ---------------- | ------------------ | ------------------------------ | | Static | `about.tsx` | `/about` | Direct URL match | | Index | `index.tsx` | `/` or parent path | Default route for directory | | Dynamic | `[id].tsx` | `/123` | Single dynamic segment | | Catch-all | `[...slug].tsx` | `/a/b/c` | Multiple dynamic segments | | Group | `(tabs)/` | Not in URL | Organize without affecting URL | | Layout | `_layout.tsx` | N/A | Wraps sibling routes | | Not Found | `+not-found.tsx` | N/A | 404 fallback | --- ## Directory Structure ``` app/ ├── _layout.tsx # Root layout ├── index.tsx # Home route (/) ├── about.tsx # /about ├── +not-found.tsx # 404 fallback ├── settings/ │ ├── _layout.tsx # Settings stack layout │ ├── index.tsx # /settings │ └── profile.tsx # /settings/profile ├── users/ │ ├── _layout.tsx # Users layout │ ├── index.tsx # /users │ └── [id].tsx # /users/:id (dynamic) ├── posts/ │ └── [...slug].tsx # /posts/a/b/c (catch-all) └── (tabs)/ # Tab navigator (group) ├── _layout.tsx # Tab layout ├── home.tsx # Tab: home ├── search.tsx # Tab: search └── profile.tsx # Tab: profile ``` --- ## Root Layout ```typescript // app/_layout.tsx import { Stack } from "expo-router"; import { useFonts } from "expo-font"; import * as SplashScreen from "expo-splash-screen"; import { useEffect } from "react"; import { StatusBar } from "expo-status-bar"; // Prevent splash screen from auto-hiding 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 ( <> <StatusBar style="auto" /> <Stack> <Stack.Screen name="index" options={{ title: "Home" }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="modal" options={{ presentation: "modal", headerShown: true, }} /> <Stack.Screen name="+not-found" /> </Stack> </> ); } ``` --- ## Tab Navigation ```typescript // app/(tabs)/_layout.tsx import { Tabs } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; const TAB_ICON_SIZE = 24; type TabIconName = keyof typeof Ionicons.glyphMap; interface TabIconProps { name: TabIconName; focusedName: TabIconName; color: string; focused: boolean; } function TabIcon({ name, focusedName, color, focused }: TabIconProps) { return ( <Ionicons name={focused ? focusedName : name} size={TAB_ICON_SIZE} color={color} /> ); } export default function TabLayout() { return ( <Tabs screenOptions={{ tabBarActiveTintColor: "#007AFF", tabBarInactiveTintColor: "#8E8E93", headerShown: true, }} > <Tabs.Screen name="index" options={{ title: "Home", tabBarIcon: ({ color, focused }) => ( <TabIcon name="home-outline" focusedName="home" color={color} focused={focused} /> ), }} /> <Tabs.Screen name="search" options={{ title: "Search", tabBarIcon: ({ color, focused }) => ( <TabIcon name="search-outline" focusedName="search" color={color} focused={focused} /> ), }} /> <Tabs.Screen name="profile" options={{ title: "Profile", tabBarIcon: ({ color, focused }) => ( <TabIcon name="person-outline" focusedName="person" color={color} focused={focused} /> ), }} /> </Tabs> ); } ``` --- ## Stack Inside Tabs (Nested Navigation) ``` app/ ├── (tabs)/ │ ├── _layout.tsx # Tab navigator │ ├── feed/ │ │ ├── _layout.tsx # Stack navigator for feed │ │ ├── index.tsx # Feed list │ │ └── [postId].tsx # Post detail │ └── settings.tsx ``` ```typescript // app/(tabs)/feed/_layout.tsx import { Stack } from "expo-router"; export default function FeedLayout() { return ( <Stack> <Stack.Screen name="index" options={{ title: "Feed" }} /> <Stack.Screen name="[postId]" options={{ title: "Post", headerBackTitle: "Feed", }} /> </Stack> ); } // app/(tabs)/feed/index.tsx import { FlatList, Pressable, Text, View } from "react-native"; import { Link } from "expo-router"; interface Post { id: string; title: string; } const POSTS: Post[] = [ { id: "1", title: "First Post" }, { id: "2", title: "Second Post" }, ]; export default function FeedScreen() { return ( <FlatList data={POSTS} keyExtractor={(item) => item.id} renderItem={({ item }) => ( <Link href={`/feed/${item.id}`} asChild> <Pressable style={{ padding: 16 }}> <Text>{item.title}</Text> </Pressable> </Link> )} /> ); } // app/(tabs)/feed/[postId].tsx import { useLocalSearchParams } from "expo-router"; import { View, Text, StyleSheet } from "react-native"; export default function PostDetailScreen() { const { postId } = useLocalSearchParams<{ postId: string }>(); return ( <View style={styles.container}> <Text style={styles.title}>Post ID: {postId}</Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, padding: 16, }, title: { fontSize: 24, fontWeight: "bold", }, }); ``` --- ## Dynamic Routes ```typescript // app/users/[id].tsx import { useLocalSearchParams, Stack } from "expo-router"; import { View, Text } from "react-native"; export default function UserScreen() { // Type-safe params const { id } = useLocalSearchParams<{ id: string }>(); return ( <> {/* Dynamically set screen title */} <Stack.Screen options={{ title: `User ${id}` }} /> <View style={{ flex: 1, padding: 16 }}> <Text>User ID: {id}</Text> </View> </> ); } ``` --- ## Catch-All Routes ```typescript // app/docs/[...slug].tsx import { useLocalSearchParams } from "expo-router"; export default function DocsScreen() { // slug is an array: /docs/api/auth/login -> ["api", "auth", "login"] const { slug } = useLocalSearchParams<{ slug: string[] }>(); const path = Array.isArray(slug) ? slug.join("/") : slug; // Render based on path segments... } ``` --- ## Navigation Hooks ```typescript // components/navigation-example.tsx import { useRouter, useLocalSearchParams, useGlobalSearchParams, usePathname, useSegments, Link, } from "expo-router"; import { View, Text, Pressable, StyleSheet } from "react-native"; export function NavigationExample() { const router = useRouter(); const { id } = useLocalSearchParams(); const globalParams = useGlobalSearchParams(); const pathname = usePathname(); const segments = useSegments(); const handlePush = () => { // Push new screen onto stack router.push("/users/123"); }; const handleReplace = () => { // Replace current screen router.replace("/home"); }; const handleBack = () => { // Go back router.back(); }; const handleNavigateWithParams = () => { // Navigate with typed params router.push({ pathname: "/users/[id]", params: { id: "456" }, }); }; const handleDismissModal = () => { // Dismiss to specific route (Expo Router 4+) router.dismissTo("/home"); }; return ( <View style={styles.container}> {/* Declarative navigation with Link */} <Link href="/about" style={styles.link}> <Text>Go to About</Text> </Link> {/* Link with asChild - pass navigation to child */} <Link href="/users/123" asChild> <Pressable style={styles.button}> <Text style={styles.buttonText}>User Profile</Text> </Pressable> </Link> {/* Link with params object */} <Link href={{ pathname: "/search", params: { query: "expo" }, }} style={styles.link} > <Text>Search for Expo</Text> </Link> {/* Imperative navigation */} <Pressable style={styles.button} onPress={handlePush}> <Text style={styles.buttonText}>Push Screen</Text> </Pressable> <Text style={styles.info}>Current path: {pathname}</Text> <Text style={styles.info}>Segments: {segments.join("/")}</Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, padding: 16, gap: 12, }, link: { padding: 12, backgroundColor: "#f0f0f0", borderRadius: 8, }, button: { padding: 12, backgroundColor: "#007AFF", borderRadius: 8, alignItems: "center", }, buttonText: { color: "#fff", fontWeight: "600", }, info: { fontSize: 12, color: "#666", }, }); ``` --- ## Authentication Flow ```typescript // app/_layout.tsx import { Stack, useRouter, useSegments } from "expo-router"; import { useEffect } from "react"; import { useAuth } from "../hooks/use-auth"; function useProtectedRoute(isAuthenticated: boolean) { const segments = useSegments(); const router = useRouter(); useEffect(() => { const inAuthGroup = segments[0] === "(auth)"; if (!isAuthenticated && !inAuthGroup) { // Redirect to login if not authenticated router.replace("/login"); } else if (isAuthenticated && inAuthGroup) { // Redirect to home if authenticated router.replace("/"); } }, [isAuthenticated, segments]); } export default function RootLayout() { const { isAuthenticated, isLoading } = useAuth(); useProtectedRoute(isAuthenticated); if (isLoading) { return <LoadingScreen />; } return ( <Stack> <Stack.Screen name="(auth)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> </Stack> ); } ``` ``` app/ ├── _layout.tsx # Root layout with auth check ├── (auth)/ # Auth screens (unprotected) │ ├── _layout.tsx │ ├── login.tsx │ └── register.tsx └── (tabs)/ # Main app (protected) ├── _layout.tsx ├── index.tsx └── profile.tsx ``` --- ## Modal Routes ```typescript // app/_layout.tsx import { Stack } from "expo-router"; export default function RootLayout() { return ( <Stack> <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="modal" options={{ presentation: "modal", headerShown: true, title: "Settings", }} /> <Stack.Screen name="sheet" options={{ presentation: "formSheet", sheetGrabberVisible: true, sheetCornerRadius: 16, }} /> </Stack> ); } // app/modal.tsx import { useRouter } from "expo-router"; import { View, Text, Pressable, StyleSheet } from "react-native"; export default function ModalScreen() { const router = useRouter(); return ( <View style={styles.container}> <Text style={styles.title}>Modal Content</Text> <Pressable style={styles.closeButton} onPress={() => router.back()} > <Text style={styles.closeText}>Close</Text> </Pressable> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, padding: 16, alignItems: "center", justifyContent: "center", }, title: { fontSize: 24, fontWeight: "bold", marginBottom: 16, }, closeButton: { padding: 12, backgroundColor: "#007AFF", borderRadius: 8, }, closeText: { color: "#fff", fontWeight: "600", }, }); ``` --- ## TypeScript Route Types ```typescript // types/navigation.ts import type { Href } from "expo-router"; // Enable typed routes in app.json: // { "experiments": { "typedRoutes": true } } // After enabling, routes are auto-generated in: // .expo/types/router.d.ts // Usage with type safety const homeRoute: Href = "/"; const userRoute: Href = "/users/123"; const searchRoute: Href = { pathname: "/search", params: { query: "test" } }; // TypeScript will error on invalid routes // const invalidRoute: Href = "/nonexistent"; // Error! ``` --- ## Shared Routes Between Tabs ``` app/(tabs)/ ├── _layout.tsx ├── (feed)/ │ └── index.tsx # Feed tab content ├── (search)/ │ └── search.tsx # Search tab content └── (feed,search)/ # Shared between both tabs ├── _layout.tsx └── users/ └── [username].tsx # Accessible from both feed and search tabs ``` ```typescript // app/(tabs)/(feed,search)/users/[username].tsx import { useLocalSearchParams } from "expo-router"; import { View, Text, StyleSheet } from "react-native"; export default function UserProfileScreen() { const { username } = useLocalSearchParams<{ username: string }>(); // This screen is accessible from both tabs // URL: /users/:username return ( <View style={styles.container}> <Text style={styles.title}>@{username}</Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, padding: 16, }, title: { fontSize: 24, fontWeight: "bold", }, }); ``` --- ## Headless Tabs (Custom Tab Layouts) SDK 52+ provides headless tab components via `expo-router/ui` for fully custom tab layouts. This feature is experimentally available in SDK 52 and later. ### Basic Headless Tabs ```typescript // app/(tabs)/_layout.tsx import { Tabs, TabList, TabTrigger, TabSlot } from "expo-router/ui"; import { Text, StyleSheet } from "react-native"; const TAB_BAR_HEIGHT = 60; export default function CustomTabLayout() { return ( <Tabs> {/* Content area */} <TabSlot /> {/* Custom tab bar - TabTrigger renders as Pressable by default */} <TabList style={styles.tabBar}> <TabTrigger name="home" href="/" style={styles.tab}> <Text>Home</Text> </TabTrigger> <TabTrigger name="search" href="/search" style={styles.tab}> <Text>Search</Text> </TabTrigger> <TabTrigger name="profile" href="/profile" style={styles.tab}> <Text>Profile</Text> </TabTrigger> </TabList> </Tabs> ); } const styles = StyleSheet.create({ tabBar: { flexDirection: "row", height: TAB_BAR_HEIGHT, backgroundColor: "#fff", borderTopWidth: 1, borderTopColor: "#e0e0e0", }, tab: { flex: 1, alignItems: "center", justifyContent: "center", }, }); ``` ### TabTrigger Props | Prop | Type | Description | | --------- | ------------------------------------ | --------------------------------------- | | `name` | string | Required identifier for the tab | | `href` | string | Required route destination (in TabList) | | `reset` | "always" \| "onLongPress" \| "never" | Navigation state reset behavior | | `asChild` | boolean | Pass navigation to child component | ### Native Tabs (SDK 54+ Alpha) SDK 54+ introduces native tabs with iOS 26 Liquid Glass support. **Note: This API is in alpha and subject to change.** ```typescript // app/(tabs)/_layout.tsx // IMPORTANT: Import from unstable-native-tabs, not expo-router import { NativeTabs } from "expo-router/unstable-native-tabs"; const TAB_BAR_TINT_COLOR = "#007AFF"; export default function TabLayout() { return ( <NativeTabs tintColor={TAB_BAR_TINT_COLOR} minimizeBehavior="onScrollDown" // iOS 26+ > <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Icon sf="house.fill" md="home" /> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="search"> <NativeTabs.Trigger.Icon sf="magnifyingglass" md="search" /> <NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Badge>3</NativeTabs.Trigger.Badge> </NativeTabs.Trigger> <NativeTabs.Trigger name="profile"> <NativeTabs.Trigger.Icon sf="person.fill" md="person" /> <NativeTabs.Trigger.Label>Profile</NativeTabs.Trigger.Label> </NativeTabs.Trigger> </NativeTabs> ); } // NOTE: Android has a limit of 5 tabs (Material Design constraint) ```
-
-
reference.md 15.1 KB
# Expo Reference > Decision frameworks, anti-patterns, and red flags. Reference from [SKILL.md](SKILL.md). --- ## Decision Framework ### Expo Go vs Development Build ``` Starting development? ├─ Prototyping or learning? │ └─ YES → Expo Go is fine ├─ Using custom native modules? │ └─ YES → Development build required ├─ Testing push notifications? │ └─ YES → Development build required ├─ Need accurate splash screen / app icon? │ └─ YES → Development build required ├─ Using libraries with native code outside Expo SDK? │ └─ YES → Development build required └─ Production testing? └─ YES → Development build required ``` ### Managed vs Bare Workflow ``` Choosing workflow? ├─ Need custom native code beyond config plugins? │ ├─ YES → Consider Expo Modules API first │ │ └─ Not sufficient → Prebuild (bare-like with CNG) │ └─ NO → Continue... ├─ Team comfortable maintaining android/ios? │ ├─ YES → Prebuild is fine │ └─ NO → Stay managed ├─ Need control over native build settings? │ ├─ YES → Prebuild with config plugins │ └─ NO → Managed (fully) └─ Default → Managed (95% of cases) ``` ### Runtime Version Strategy ``` Choosing runtimeVersion policy? ├─ Simple app, minimal native dependencies? │ └─ "appVersion" - updates work within same version ├─ Complex native dependencies? │ └─ "fingerprint" - auto-detects native changes ├─ Want explicit control? │ └─ Use exact string like "1.0.0" ├─ Need cross-SDK updates? │ └─ "sdkVersion" - but carefully managed └─ Default → "appVersion" (easiest to understand) ``` ### Build Profile Selection ``` Which build profile? ├─ Local development testing? │ ├─ On simulator/emulator → development (simulator: true) │ └─ On physical device → development-device ├─ Testing with real team? │ └─ preview (internal distribution) ├─ App store submission? │ └─ production └─ CI/CD builds? ├─ PR builds → preview └─ Main branch → production ``` ### Update Channel Strategy ``` Which update channel? ├─ Development builds │ └─ development channel (or none) ├─ Internal testing (preview builds) │ └─ preview channel ├─ App Store releases │ └─ production channel └─ Hotfix? └─ Same channel as affected build ``` --- ## Expo Router Decision Framework ### Route Type Selection ``` What type of route? ├─ Static page (about, settings)? │ └─ about.tsx → /about ├─ Dynamic content (user profile, product)? │ └─ [id].tsx → /users/:id ├─ Nested path (documentation sections)? │ └─ [...slug].tsx → /docs/a/b/c ├─ Tab navigation? │ └─ (tabs)/ group with _layout.tsx ├─ Auth-protected section? │ └─ Conditional navigator in _layout.tsx └─ Modal? └─ presentation: 'modal' in screen options ``` ### Navigation Method ``` How to navigate? ├─ Static link in UI? │ └─ <Link href="/path"> ├─ Dynamic navigation in handler? │ └─ router.push("/path") ├─ Replace current screen? │ └─ router.replace("/path") ├─ Go back? │ └─ router.back() ├─ Dismiss modal to specific route? │ └─ router.dismissTo("/path") ├─ Dismiss all screens in stack? │ └─ router.dismissAll() ├─ Prefetch for performance? │ └─ router.prefetch("/path") └─ Check if can go back/dismiss? └─ router.canGoBack() / router.canDismiss() ``` --- ## SDK Gotchas and Edge Cases > Core red flags are in [SKILL.md](SKILL.md). These are additional SDK-specific gotchas for reference. - **Mixing version numbers incorrectly** - iOS buildNumber must be string, Android versionCode must be integer - **Not handling edge-to-edge display (Android)** - mandatory in SDK 54, cannot be disabled; use react-native-safe-area-context - **Using expo-file-system without updating imports (SDK 54+)** - default imports changed; legacy API moved to `expo-file-system/legacy` - **Missing SplashScreen.preventAutoHideAsync()** - flash of white/blank screen while fonts load - **Not handling update errors gracefully** - app crashes instead of continuing with current version - **Using `@expo/vector-icons` incorrectly in production** - prefer custom icon fonts for smaller bundle - **Not setting up iOS provisioning profiles for internal distribution** - preview builds fail to install - **Forgetting to configure EAS project ID** - updates and builds fail with cryptic errors - **iOS simulator builds won't install on devices** - need separate device build profile - **runtimeVersion "fingerprint" can be too aggressive** - flags changes that don't affect native code - **EAS Update has ~50MB limit** - large assets should use CDN, not bundled - **expo-dev-client overrides Expo Go** - can't use both in same build - **Android versionCode must strictly increase** - Play Store rejects same or lower values - **Push notifications removed from Expo Go (Android) in SDK 53** - use development builds - **Google Maps removed from Expo Go (Android) in SDK 53** - use development builds or expo-maps - **React 19 breaking changes in SDK 53** - state updates are batched differently; review React 19 upgrade guide - **AppDelegate is Swift in SDK 53+** - config plugins must use Swift modifications, not Objective-C - **package.json exports enforced in SDK 53** - Metro enforces ES Module resolution; some libraries may break - **expo-av completely removed in SDK 55** - must migrate to expo-video/expo-audio before upgrading - **`removeSubscription` deprecated across SDK 55 packages** - use `subscription.remove()` instead - **Legacy Architecture removed in SDK 55** - `newArchEnabled` flag no longer exists; New Architecture is mandatory - **Native tabs are alpha (SDK 54+)** - import from `expo-router/unstable-native-tabs`, API may change - **`eas update --channel` replaced by `--environment` in SDK 55** - old flag no longer works - **Android limited to 5 native tabs** - Material Design constraint, cannot be overridden --- ## Anti-Patterns to Avoid > Detailed anti-patterns with code examples. See [SKILL.md](SKILL.md) for the summary red flags list. ### Anti-Pattern 1: Expo Go for Production Testing ```typescript // ANTI-PATTERN: Testing production features in Expo Go // Expo Go doesn't support: // - Push notifications (no project credentials) // - Custom native modules // - Accurate splash/icons // - Deep linking with custom schemes // - Many Expo SDK features requiring dev builds // Result: "Works in development, crashes in production" ``` **Why it's wrong:** Expo Go is a generic client without your app's native configuration. Features relying on native setup will fail silently or crash. **What to do instead:** ```bash # Create development build for accurate testing eas build --profile development --platform ios # Or build locally npx expo run:ios ``` --- ### Anti-Pattern 2: Manual Native Directory Edits ```typescript // ANTI-PATTERN: Editing android/app/build.gradle directly android { defaultConfig { minSdkVersion 24 // Manual edit - will be lost! } } // After `npx expo prebuild --clean`: // Your changes are GONE ``` **Why it's wrong:** Expo's Continuous Native Generation treats android/ios as build artifacts. Manual changes don't survive regeneration. **What to do instead:** ```typescript // app.config.ts - Use config plugins export default { plugins: [ [ "expo-build-properties", { android: { minSdkVersion: 24, }, }, ], ], }; ``` --- ### Anti-Pattern 3: Secrets in `EXPO_PUBLIC_` Variables ```bash # ANTI-PATTERN: Exposing secrets EXPO_PUBLIC_API_KEY=sk_live_xxx123 # EXPOSED IN BUNDLE! EXPO_PUBLIC_DATABASE_URL=postgres://user:pass@host/db # EXPOSED! ``` **Why it's wrong:** `EXPO_PUBLIC_` variables are embedded in the JavaScript bundle and visible to anyone who decompiles the app. **What to do instead:** ```bash # EAS Secrets for sensitive build-time values eas secret:create --name API_KEY --value "sk_live_xxx123" # Access via server - never client-side # Use backend proxy for sensitive operations ``` --- ### Anti-Pattern 4: Missing runtimeVersion Updates ```typescript // ANTI-PATTERN: Not updating runtimeVersion after native changes // Week 1: Ship with expo-camera // Week 2: Add expo-notifications (native dependency) // Week 3: OTA update without updating runtimeVersion // Result: Crash! Old builds don't have notification native code ``` **Why it's wrong:** OTA updates can only change JavaScript. If native code changed, the update crashes because expected native modules don't exist. **What to do instead:** ```typescript // app.config.ts export default { runtimeVersion: { policy: "fingerprint", // Auto-detects native changes }, // OR explicit version bump when adding native deps // runtimeVersion: "2.0.0", }; ``` --- ### Anti-Pattern 5: Destructuring Environment Variables ```typescript // ANTI-PATTERN: Metro can't statically analyze this // These DON'T work: const { EXPO_PUBLIC_API_URL } = process.env; // undefined const url = process.env["EXPO_PUBLIC_API_URL"]; // undefined const keys = Object.keys(process.env); // Doesn't include EXPO_PUBLIC_* // CORRECT: Direct property access only const API_URL = process.env.EXPO_PUBLIC_API_URL; // Works! ``` **Why it's wrong:** Metro bundler requires static analysis to inline environment variables. Dynamic access patterns can't be resolved at build time. **What to do instead:** ```typescript // config/env.ts const API_URL = process.env.EXPO_PUBLIC_API_URL; const SENTRY_DSN = process.env.EXPO_PUBLIC_SENTRY_DSN; if (!API_URL) { throw new Error("EXPO_PUBLIC_API_URL is required"); } export const env = { apiUrl: API_URL, sentryDsn: SENTRY_DSN, } as const; ``` --- ### Anti-Pattern 6: Ignoring Platform Testing ```typescript // ANTI-PATTERN: Only testing on one platform // "It works on iOS simulator" // Ship to Play Store // Crash reports flood in // Common iOS-specific patterns that break on Android: // - SafeAreaView behavior differences // - Shadow properties (iOS) vs elevation (Android) // - Font weight values // - StatusBar handling ``` **Why it's wrong:** Platform differences compound. Small issues become major problems when discovered after release. **What to do instead:** ```bash # Test on both platforms during development npx expo start --ios npx expo start --android # Create preview builds for both eas build --profile preview --platform all # Verify on physical devices before release ``` --- ## Expo SDK Compatibility Reference ### SDK 52 - New Architecture enabled by default for new projects - React Native 0.76 - Expo Router v4 with `dismissTo` - iOS 18 support, iOS minimum raised to 15.1 - Android minSdkVersion 24, compileSdkVersion 35 - `expo-video` stable (replaces expo-av Video) - Headless `<Tabs />` component (expo-router/ui) ### SDK 53 - New Architecture enabled by default for ALL projects - React Native 0.79 with React 19 - Edge-to-edge display enabled by default (Android) - `expo-audio` stable (replaces expo-av Audio) - `expo-background-task` (replaces expo-background-fetch) - `expo/fetch` with streaming support - AppDelegate migrated to Swift (iOS) - Push notifications removed from Expo Go (Android) ### SDK 54 - React Native 0.81 with React 19.1 - Expo Router v6 with Native Tabs (alpha via `expo-router/unstable-native-tabs`) - iOS 26 Liquid Glass support with Expo UI (beta) - Android 16 target (API 36), edge-to-edge mandatory and cannot be disabled - Precompiled React Native XCFrameworks for faster iOS builds (~10x improvement) - expo-file-system new API is default (legacy moved to `expo-file-system/legacy`) - **Final SDK supporting Legacy Architecture** - React Native 0.82+ won't permit opting out - Deprecated expo-notifications function exports removed - Minimum Node.js bumped to 20.19.4 - Minimum Xcode bumped to 16.1 (Xcode 26 recommended) ### SDK 55 (Latest) - React Native 0.83 with React 19.2 - Expo Router v7 with Stack.Toolbar, Apple Zoom transitions, SplitView (experimental) - **Legacy Architecture support removed** - New Architecture is the only option, `newArchEnabled` flag removed - `expo-av` completely removed from Expo Go and SDK - use `expo-video` and `expo-audio` - `edgeToEdgeEnabled` removed from app.json - edge-to-edge is mandatory on Android 16+ - 75% smaller OTA update downloads with Hermes bytecode diffing (opt-in via `enableBsdiffPatchSupport`) - Hermes v1 opt-in via `useHermesV1` in expo-build-properties (better ES6+ support, increased build times) - `eas update` now requires `--environment` flag (replaces `--channel`) - `expo-server` package (renamed from `@expo/server`) ships as part of SDK - `expo-widgets` for iOS home screen widgets and Live Activities - `expo-brownfield` for adding Expo to existing native apps - New default template uses native tabs and `/src/app` directory structure - NativeTabs compound component API: use `NativeTabs.Trigger.Icon` instead of separate `Icon` import - All SDK packages use matching major versions (expo-camera for SDK 55 is `^55.0.0`) - `removeSubscription` function exports deprecated across packages (use `subscription.remove()`) - `expo-video-thumbnails` deprecated (use `expo-video` instead) - Minimum Node.js: ^20.19.4, ^22.13.0, ^24.3.0, ^25.0.0 ### Migration Checklist When upgrading SDK: - [ ] Run `npx expo install expo@latest` - [ ] Run `npx expo install --fix` for peer deps - [ ] Run `npx expo-doctor` for validation - [ ] Check deprecated APIs in changelog - [ ] Run `npx expo prebuild --clean` - [ ] Test on both platforms - [ ] Update runtimeVersion if native changes --- ## CLI Commands Quick Reference ### Development ```bash npx expo start # Start dev server npx expo start --clear # Clear cache and start npx expo start --ios # Start with iOS npx expo start --android # Start with Android npx expo run:ios # Build and run iOS locally npx expo run:android # Build and run Android locally ``` ### Project Management ```bash npx expo install [package] # Install with correct version npx expo install --fix # Fix peer dependencies npx expo-doctor # Validate project configuration npx expo prebuild # Generate native directories npx expo prebuild --clean # Clean regeneration ``` ### EAS Build ```bash eas build --profile [profile] --platform [ios|android|all] eas build:list # List builds eas build:view [id] # View build details eas build:run --platform [platform] # Run built app ``` ### EAS Update ```bash eas update --environment [preview|production] --message "description" # SDK 55+ eas update --channel [channel] --message "description" # SDK 54 and earlier eas update:list # List updates eas update:rollback --channel [channel] ``` ### EAS Submit ```bash eas submit --platform [ios|android] eas submit --platform [platform] --id [build-id] ``` ### EAS Credentials ```bash eas credentials --platform [ios|android] eas secret:create --name [name] --value [value] eas secret:list eas device:create # Register iOS device eas device:list ``` -
SKILL.md 7.6 KB
--- name: mobile-framework-expo description: Expo managed workflow --- # Expo Development Patterns > **Quick Guide:** Build production-ready React Native apps with Expo. Use managed workflow with Continuous Native Generation for most projects, Expo Router for file-based navigation, and EAS for builds/updates. Development builds replace Expo Go for production testing. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST use development builds for production testing - Expo Go is for prototyping only)** **(You MUST update runtimeVersion when making native dependency changes to prevent OTA update crashes)** **(You MUST use config plugins for native customization - NEVER manually edit android/ios directories in managed workflow)** **(You MUST use `EXPO_PUBLIC_` prefix for client-side environment variables - NEVER store secrets in these variables)** </critical_requirements> --- **Auto-detection:** Expo, expo-router, EAS Build, EAS Update, expo-dev-client, app.config.js, app.json, expo prebuild, npx expo, eas.json, expo-constants, expo-notifications, Continuous Native Generation, CNG **When to use:** - Starting new React Native projects with rapid development needs - Building apps that need OTA (over-the-air) updates - Using file-based routing with convention-over-configuration - Managing native code without maintaining android/ios directories - Deploying to app stores with cloud builds **Key patterns covered:** - Managed workflow with Continuous Native Generation (CNG) - Expo Router file-based navigation - EAS Build, Submit, and Update workflows - Development builds vs Expo Go - Config plugins for native customization - Environment configuration and secrets - Push notifications setup **When NOT to use:** - Apps requiring complex custom native code beyond Expo Modules API - When app size must be under 15MB (Expo adds overhead) - Legacy React Native projects not ready for migration --- <philosophy> ## Philosophy Expo transforms React Native development from "write once, debug everywhere" to "write once, deploy confidently." The key insight is that **most apps don't need direct native access** - they need well-maintained native modules with consistent APIs. **Core principles:** 1. **Managed by default** - Let Expo handle native complexity; prebuild only when necessary 2. **Continuous Native Generation** - Treat android/ios as build artifacts, not source code 3. **Development builds for truth** - Expo Go is for learning; development builds show production reality 4. **OTA for velocity** - Ship JavaScript updates without app store delays 5. **Config plugins over ejection** - Customize native code declaratively when needed **Mental model:** Expo is NOT a limitation on React Native - it's a professional-grade abstraction. You can always drop down to native code via Expo Modules API or prebuild, but most apps never need to. </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Dynamic Configuration with `app.config.ts` Use `app.config.ts` for environment-specific builds. Use named constants for SDK versions and build numbers. ```typescript // app.config.ts - Environment-aware config const IS_PRODUCTION = process.env.APP_ENV === "production"; const BUILD_NUMBER = 1; export default ({ config }: ConfigContext): ExpoConfig => ({ ...config, name: IS_PRODUCTION ? "MyApp" : "MyApp (Dev)", ios: { bundleIdentifier: IS_PRODUCTION ? "com.app" : "com.app.dev", buildNumber: String(BUILD_NUMBER), }, android: { package: IS_PRODUCTION ? "com.app" : "com.app.dev", versionCode: BUILD_NUMBER, }, }); ``` > Full examples: [examples/core.md](examples/core.md) - App Configuration section --- ### Pattern 2: Config Plugins for Native Customization Modify native code declaratively -- changes survive `expo prebuild --clean`. Use config plugins for permissions, SDK versions, and native settings. ```typescript // app.config.ts plugins array plugins: [ [ "expo-camera", { cameraPermission: "Allow $(PRODUCT_NAME) to access your camera." }, ], [ "expo-build-properties", { android: { minSdkVersion: 24 }, ios: { deploymentTarget: "15.1" } }, ], ]; ``` > Full examples: [examples/core.md](examples/core.md) - Config Plugins section --- ### Pattern 3: Environment Variables Use `EXPO_PUBLIC_` prefix for client-side variables. Metro requires direct property access -- destructuring and bracket notation don't work. ```typescript // MUST use direct access - Metro static analysis requirement const API_URL = process.env.EXPO_PUBLIC_API_URL; // Works // const { EXPO_PUBLIC_API_URL } = process.env; // BROKEN - undefined at runtime ``` > Full examples: [examples/core.md](examples/core.md) - Environment Variables section --- ### Pattern 4: Development Builds Use `expo-dev-client` for production-accurate testing. Expo Go is for prototyping only -- it lacks your native dependencies, push notifications, and accurate splash screens. ```bash # Cloud build eas build --profile development --platform ios # Local build npx expo run:ios ``` > Full configuration: [examples/eas.md](examples/eas.md) - Development Builds section --- ### Pattern 5: Asset Management Block splash screen while loading fonts, use `expo-image` for remote images with blur hash placeholders and disk caching. ```typescript SplashScreen.preventAutoHideAsync(); // Load fonts, then call SplashScreen.hideAsync() when ready ``` > Full examples: [examples/core.md](examples/core.md) - Font Loading and Image Handling sections </patterns> --- <red_flags> ## RED FLAGS - **Expo Go for production testing** -- missing native modules, push notifications, accurate splash screens. Always use development builds. - **Not updating runtimeVersion after native changes** -- OTA updates crash on apps with incompatible native code. Use `"fingerprint"` policy for automatic detection. - **Storing secrets in `EXPO_PUBLIC_` variables** -- embedded in JS bundle, visible to anyone who decompiles. Use EAS Secrets and backend proxies. - **Manually editing android/ios directories** -- changes lost on `expo prebuild --clean`. Use config plugins. - **Destructuring `process.env`** -- Metro requires direct property access (`process.env.EXPO_PUBLIC_*`). Destructuring and bracket notation produce `undefined`. - **Using `expo-av`** -- removed in SDK 55. Migrate to `expo-video` and `expo-audio`. - **Legacy Architecture** -- removed after SDK 54. React Native 0.82+ requires New Architecture. > Full anti-patterns and gotchas: [reference.md](reference.md) </red_flags> --- **Detailed Resources:** - [examples/core.md](examples/core.md) - Project config, environment variables, fonts, images - [examples/router.md](examples/router.md) - File-based routing, tabs, auth flows, modals - [examples/eas.md](examples/eas.md) - Cloud builds, app store submission, OTA updates - [reference.md](reference.md) - Decision frameworks, SDK compatibility, anti-patterns --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** **(You MUST use development builds for production testing - Expo Go is for prototyping only)** **(You MUST update runtimeVersion when making native dependency changes to prevent OTA update crashes)** **(You MUST use config plugins for native customization - NEVER manually edit android/ios directories in managed workflow)** **(You MUST use `EXPO_PUBLIC_` prefix for client-side environment variables - NEVER store secrets in these variables)** **Failure to follow these rules will cause OTA update crashes, broken builds, and security vulnerabilities.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.