Claude Skill

mobile-framework-expo

Expo managed workflow

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_mobile-framework-expo_skills_mobile-framework-expo-3a51ef5.zip · 19 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/mobile-framework-expo/skills/mobile-framework-expo
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git 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 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

</red_flags>


Detailed Resources:


<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.

No comments yet.

Reviews (0)

No reviews yet.

Related