Steven's Knowledge

Expo Ecosystem

React Native Expo — managed vs bare workflow, Expo Router, EAS, Expo Modules API, CNG

Expo Ecosystem

Expo is an SDK, toolchain, and service layer for React Native. It is not a separate framework — it is the recommended way to build, ship, and update React Native applications. The React Native documentation itself directs new projects to start with Expo. This page covers the ecosystem as of 2025: CNG, Expo Router, EAS services, the Modules API, and the configuration system.

Expo in 2025

What Expo Actually Is

Expo is three things:

LayerWhat it provides
Expo SDKA curated set of cross-platform native modules (camera, file system, notifications, etc.) with a unified JS API
Expo CLI / Toolchainnpx expo start, npx expo prebuild, metro config, TypeScript templates, dev client
Expo Application Services (EAS)Cloud builds, OTA updates, store submissions, metadata management

You can use any layer independently. Many teams use the SDK modules without EAS, or use EAS Build without Expo Router. The layers compose but do not require each other.

The End of "Ejecting"

The old mental model was: start with Expo, hit a wall, "eject" to bare React Native, lose Expo tooling forever. That model is dead.

The replacement is Continuous Native Generation (CNG): your app.json / app.config.ts plus config plugins define the native project. You run npx expo prebuild to generate ios/ and android/ directories from that config. You can regenerate them at any time. You never lose access to native code — you just decide whether to manage it by hand or generate it from config.

If your team previously "ejected" from Expo, you can migrate back. The modern Expo toolchain works with any React Native project. Install the expo package, adopt modules incrementally, and use npx expo prebuild when you are ready to hand native project management back to config plugins.

Managed vs Bare vs CNG

Managed Workflow

No ios/ or android/ folders exist in version control. Expo handles all native code. You write JS/TS only and rely on Expo SDK modules and config plugins for native behavior.

# Create a managed project
npx create-expo-app@latest my-app
cd my-app
npx expo start

Ideal for: teams without native iOS/Android developers, MVPs, apps that stay within the Expo SDK surface area.

Bare Workflow

Full access to ios/ and android/ directories. You manage Xcode projects, Podfiles, Gradle scripts directly. Expo SDK modules still work — you just install and link them yourself.

# Create with bare template
npx create-expo-app@latest my-app --template bare-minimum

Ideal for: brownfield integration, apps requiring custom native build pipelines, teams with dedicated native engineers.

CNG (Continuous Native Generation)

The middle path — and the recommended default. Native directories are generated from config, used for building, and optionally discarded afterward. Config plugins handle native modifications that used to require manual Xcode/Gradle changes.

# Generate native projects from config
npx expo prebuild

# Generate and clean existing native dirs first
npx expo prebuild --clean

# Platform-specific generation
npx expo prebuild --platform ios

After prebuild, you have full ios/ and android/ directories. You can inspect them, debug in Xcode/Android Studio, or run platform-specific tools. The difference from bare workflow is that these directories are derived artifacts — your source of truth is app.config.ts plus config plugins.

Workflow Comparison

AspectManagedCNG (Prebuild)Bare
Native code in repoNoOptional (can gitignore)Yes, committed
Native customizationConfig plugins onlyConfig plugins + inspect generated outputDirect native code
Upgrade costLow (Expo handles)Low (regenerate)High (manual merge)
CI complexityLowMediumHigh
Debugging native crashesLimited (EAS logs)Full (Xcode/AS after prebuild)Full
Third-party native SDKsMust have config pluginConfig plugin or patch post-prebuildDirect integration
Team native expertise neededNoneMinimalSignificant

Decision Tree

Use CNG unless:

  • You are embedding React Native into an existing native app (brownfield) — use bare.
  • You need zero native toolchain setup and stay within Expo SDK — use managed.
  • You have a complex native build pipeline with custom Gradle plugins or Xcode build phases that resist config plugin abstraction — use bare.

For most greenfield apps in 2025, CNG is the right choice.

Expo Router

Expo Router brings file-system-based routing to React Native — the same paradigm as Next.js, but for native mobile (and web, if you target it).

Why Expo Router

  • File = route. No manual route registration. Drop a file in app/, it becomes a screen.
  • Automatic deep linking. Every route gets a URL automatically.
  • Typed routes. Full TypeScript inference from the file system.
  • Universal. Same routing code works on iOS, Android, and web.

Directory Structure

app/
├── _layout.tsx              Root layout (wraps all routes)
├── index.tsx                / (home screen)
├── settings.tsx             /settings
├── (auth)/                  Route group (no URL segment)
│   ├── _layout.tsx          Auth layout
│   ├── login.tsx            /login
│   └── register.tsx         /register
├── (tabs)/                  Tab navigator group
│   ├── _layout.tsx          Tab layout
│   ├── home.tsx             /home (tab)
│   ├── search.tsx           /search (tab)
│   └── profile.tsx          /profile (tab)
├── orders/
│   ├── index.tsx            /orders
│   └── [id].tsx             /orders/123 (dynamic route)
├── products/
│   └── [category]/
│       └── [id].tsx         /products/shoes/abc (nested dynamic)
└── modal.tsx                Modal route

Root Layout

// app/_layout.tsx
import { Stack } from 'expo-router';
import { ThemeProvider } from '@/providers/ThemeProvider';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/core/api/queryClient';

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>
        <Stack>
          <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
          <Stack.Screen
            name="modal"
            options={{ presentation: 'modal', headerTitle: 'Details' }}
          />
        </Stack>
      </ThemeProvider>
    </QueryClientProvider>
  );
}

Tab Layout

// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export default function TabLayout() {
  return (
    <Tabs screenOptions={{ tabBarActiveTintColor: '#0066CC' }}>
      <Tabs.Screen
        name="home"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home" color={color} size={size} />
          ),
        }}
      />
      <Tabs.Screen
        name="search"
        options={{
          title: 'Search',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="search" color={color} size={size} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profile',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="person" color={color} size={size} />
          ),
        }}
      />
    </Tabs>
  );
}

Dynamic Routes and Typed Navigation

// app/orders/[id].tsx
import { useLocalSearchParams, router } from 'expo-router';
import { View, Text, Pressable } from 'react-native';

export default function OrderDetail() {
  const { id } = useLocalSearchParams<{ id: string }>();

  return (
    <View>
      <Text>Order: {id}</Text>
      <Pressable onPress={() => router.push('/orders')}>
        <Text>Back to orders</Text>
      </Pressable>
    </View>
  );
}

// Navigate with type safety from elsewhere
import { router } from 'expo-router';

router.push({ pathname: '/orders/[id]', params: { id: 'abc-123' } });

Deep Linking

Expo Router provides automatic deep linking for every route. No manual linking config is needed.

For Universal Links (iOS) and App Links (Android), configure the associated domains in app.config.ts:

// app.config.ts
export default {
  expo: {
    ios: {
      associatedDomains: ['applinks:example.com'],
    },
    android: {
      intentFilters: [
        {
          action: 'VIEW',
          autoVerify: true,
          data: [{ scheme: 'https', host: 'example.com', pathPrefix: '/orders' }],
        },
      ],
    },
  },
};

A link to https://example.com/orders/abc-123 opens the app directly to the OrderDetail screen.

Expo Router vs React Navigation

AspectExpo RouterReact Navigation
Route definitionFile-system conventionExplicit JS configuration
Deep linkingAutomaticManual linking config
Type safetyInferred from file namesManual ParamList types
Web supportBuilt-inExperimental
Migration costRequires app/ directory restructureWorks with any project structure
FlexibilityConvention-drivenMaximum control
MaturityNewer, rapidly evolvingBattle-tested since 2017

React Navigation is not going away. Expo Router is built on top of React Navigation. If you need navigation patterns that do not map to file-system conventions (complex conditional navigators, custom transition logic, deeply nested imperative navigation), React Navigation remains fully supported. Many production apps use both: Expo Router for the primary structure, React Navigation APIs for edge cases.

Expo SDK Modules

The Expo SDK provides drop-in native modules with a consistent API surface. They are independently versioned, work in both managed and bare workflows, and are maintained by the Expo team.

Key Modules

ModuleReplaces / ProvidesNotes
expo-imagereact-native-fast-image, RN <Image>Disk + memory cache, blurhash, transitions, SVG, animated formats
expo-camerareact-native-cameraCamera preview, barcode scanning, photo/video capture
expo-file-systemreact-native-fsRead/write files, download/upload with progress
expo-notificationsreact-native-push-notificationLocal + remote push, categories, scheduling
expo-secure-storereact-native-keychainKeychain (iOS) / Keystore (Android) for small secrets
expo-locationreact-native-geolocationForeground + background location, geofencing
expo-hapticsreact-native-haptic-feedbackHaptic feedback patterns
expo-splash-screenreact-native-splash-screenSplash screen management with preventAutoHideAsync
expo-updatesCodePushOTA JS bundle updates with channels and rollback
expo-avreact-native-video, react-native-soundAudio/video playback and recording

When to Use Expo Modules vs Community Packages

Use Expo modules when:

  • The Expo module covers your requirements (most common cases).
  • You want consistent API design and upgrade path across modules.
  • You use CNG workflow and want config plugin support out of the box.

Use community packages when:

  • The Expo module does not exist for that capability (e.g., BLE, advanced Bluetooth, specific payment SDKs).
  • The community package has significantly more features for your use case (e.g., react-native-maps for advanced map customization).
  • You need a feature the Expo module has not yet shipped.

expo-image Example

import { Image, type ImageSource } from 'expo-image';
import { StyleSheet, View } from 'react-native';

const blurhash = 'LEHV6nWB2yk8pyo0adR*.7kCMdnj';

interface ProductImageProps {
  uri: string;
  width: number;
  height: number;
}

export function ProductImage({ uri, width, height }: ProductImageProps) {
  return (
    <Image
      source={{ uri }}
      placeholder={{ blurhash }}
      contentFit="cover"
      transition={200}
      cachePolicy="memory-disk"
      recyclingKey={uri}
      style={{ width, height, borderRadius: 8 }}
    />
  );
}

// Prefetch images for upcoming screens
export async function prefetchProductImages(urls: string[]) {
  await Image.prefetch(urls);
}

// Clear cache when storage is constrained
export async function clearImageCache() {
  await Image.clearDiskCache();
  await Image.clearMemoryCache();
}

react-native-fast-image is unmaintained. It has not received meaningful updates since 2022 and does not support the New Architecture. expo-image is the production replacement: it supports blurhash placeholders, animated formats (GIF, WebP, AVIF), SVG, and both memory and disk caching with configurable policies.

Expo Modules API

The Expo Modules API lets you write custom native modules in Swift and Kotlin — not Objective-C and Java. It provides a declarative, type-safe API that handles the bridge/JSI plumbing for you.

When to Use Expo Modules API vs TurboModules

AspectExpo Modules APITurboModules (Codegen)
LanguagesSwift + KotlinObj-C/C++ + Java/Kotlin
Spec definitionSwift/Kotlin DSLTypeScript spec file
LifecycleExpo module lifecycle hooksRN application lifecycle
ComplexityLower (declarative)Higher (Codegen, C++ glue)
Expo dependencyRequires expo packageWorks without Expo
Use caseExpo-based projectsFramework-agnostic libraries

For projects already using Expo (which is most new projects), the Expo Modules API is simpler. For libraries that need to work outside the Expo ecosystem, TurboModules remain the right choice.

Creating a Custom Module

# Scaffold a new module
npx create-expo-module@latest my-native-analytics

Swift Implementation (iOS)

// ios/MyNativeAnalyticsModule.swift
import ExpoModulesCore

public class MyNativeAnalyticsModule: Module {
  public func definition() -> ModuleDefinition {
    Name("MyNativeAnalytics")

    // Sync function — runs on JS thread, returns immediately
    Function("getDeviceId") { () -> String in
      return UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
    }

    // Async function — runs on background thread
    AsyncFunction("trackEvent") { (name: String, properties: [String: Any]) in
      // Forward to your analytics SDK
      AnalyticsSDK.shared.track(event: name, properties: properties)
    }

    // Events — push data from native to JS
    Events("onAnalyticsReady", "onSessionStart")

    // Called when the module is instantiated
    OnCreate {
      AnalyticsSDK.shared.initialize { [weak self] in
        self?.sendEvent("onAnalyticsReady", [:])
      }
    }

    // View component
    View(MyNativeChartView.self) {
      Prop("dataPoints") { (view, points: [Double]) in
        view.updateData(points)
      }

      Events("onPointSelected")
    }
  }
}

Kotlin Implementation (Android)

// android/src/main/java/expo/modules/mynativeanalytics/MyNativeAnalyticsModule.kt
package expo.modules.mynativeanalytics

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import android.provider.Settings

class MyNativeAnalyticsModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("MyNativeAnalytics")

    Function("getDeviceId") {
      Settings.Secure.getString(
        appContext.reactContext?.contentResolver,
        Settings.Secure.ANDROID_ID
      ) ?: "unknown"
    }

    AsyncFunction("trackEvent") { name: String, properties: Map<String, Any> ->
      AnalyticsSDK.instance.track(name, properties)
    }

    Events("onAnalyticsReady", "onSessionStart")

    OnCreate {
      AnalyticsSDK.instance.initialize {
        sendEvent("onAnalyticsReady", emptyMap<String, Any>())
      }
    }
  }
}

Using the Module from JS/TS

// src/modules/analytics.ts
import { requireNativeModule } from 'expo-modules-core';
import { EventEmitter, type Subscription } from 'expo-modules-core';

const NativeAnalytics = requireNativeModule('MyNativeAnalytics');
const emitter = new EventEmitter(NativeAnalytics);

export function getDeviceId(): string {
  return NativeAnalytics.getDeviceId();
}

export async function trackEvent(
  name: string,
  properties: Record<string, unknown> = {},
): Promise<void> {
  await NativeAnalytics.trackEvent(name, properties);
}

export function onAnalyticsReady(callback: () => void): Subscription {
  return emitter.addListener('onAnalyticsReady', callback);
}

EAS (Expo Application Services)

EAS is a suite of cloud services for building, submitting, and updating React Native apps. It replaces fragmented solutions (Bitrise + CodePush + Fastlane) with a single integrated toolchain.

EAS Build

Cloud-based native builds. No local Xcode or Android Studio required for CI.

Build Profiles

// eas.json
{
  "cli": {
    "version": ">= 12.0.0",
    "appVersionSource": "remote"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": {
        "simulator": true
      },
      "env": {
        "EXPO_PUBLIC_API_URL": "https://api-dev.example.com"
      }
    },
    "preview": {
      "distribution": "internal",
      "ios": {
        "resourceClass": "m-medium"
      },
      "env": {
        "EXPO_PUBLIC_API_URL": "https://api-staging.example.com"
      },
      "channel": "preview"
    },
    "production": {
      "autoIncrement": true,
      "ios": {
        "resourceClass": "m-medium"
      },
      "env": {
        "EXPO_PUBLIC_API_URL": "https://api.example.com"
      },
      "channel": "production"
    }
  },
  "submit": {
    "production": {
      "ios": {
        "ascAppId": "1234567890",
        "appleTeamId": "ABCDEF1234"
      },
      "android": {
        "serviceAccountKeyPath": "./google-service-account.json",
        "track": "internal"
      }
    }
  }
}

Running Builds

# Development build (includes dev tools)
npx eas-cli build --profile development --platform ios

# Preview build (for internal testers)
npx eas-cli build --profile preview --platform all

# Production build
npx eas-cli build --profile production --platform all

# Local build (uses your machine instead of EAS cloud)
npx eas-cli build --profile development --platform ios --local

Local builds are useful for debugging build failures, testing config plugin output, and environments where cloud builds are not permitted. They require the full native toolchain (Xcode for iOS, Android SDK for Android) installed locally.

Custom Build Steps

For advanced pipelines, EAS Build supports custom build steps via eas-build-pre-install.sh, eas-build-post-install.sh, and eas-build-on-success.sh hooks, or the more powerful build.yml custom workflows:

#!/bin/bash
# eas-build-pre-install.sh
# Install system dependencies not included in the default image
apt-get install -y libvips-dev

EAS Submit

Automate store submissions directly from EAS Build artifacts.

# Submit the latest build to App Store Connect
npx eas-cli submit --platform ios --latest

# Submit to Google Play internal track
npx eas-cli submit --platform android --latest

# Submit a specific build
npx eas-cli submit --platform ios --id <build-id>

Submission profiles in eas.json configure the target (App Store Connect app ID, Google Play track, service account credentials). This integrates into CI so that merging to main triggers build + submit automatically.

EAS Update

OTA (over-the-air) JavaScript bundle updates. This is the replacement for CodePush. It pushes new JS bundles to users without a full app store review cycle.

How It Works

App startup
  ├── Check for updates (background)
  ├── Download new bundle if available
  └── Apply on next launch (or immediately with fallback)

Updates are scoped by channel and runtime version:

  • Channel: a named deployment target (e.g., production, staging, preview).
  • Runtime version: identifies the native binary version. An update is only compatible with the runtime version it was built for.
  • Branch: maps to a channel. Updates published to a branch are served to apps on the corresponding channel.

Publishing Updates

# Publish an update to the production branch
npx eas-cli update --branch production --message "Fix checkout validation"

# Publish to a preview branch
npx eas-cli update --branch preview --message "New onboarding flow"

# Publish with automatic branch detection from git
npx eas-cli update --auto

Runtime Version Pinning

This is the most critical safety mechanism in EAS Update. A runtime version mismatch means the JS bundle expects native APIs that do not exist, causing crashes.

// app.config.ts
export default {
  expo: {
    runtimeVersion: {
      policy: 'fingerprint',  // auto-generated from native dependencies
    },
    updates: {
      url: 'https://u.expo.dev/<project-id>',
      fallbackToCacheTimeout: 3000,
    },
  },
};

The fingerprint policy automatically generates a hash from your native dependencies, native code, and config plugins. When you add a new native module, the fingerprint changes, and old updates will not be served to the new binary.

Never use a static runtime version string with EAS Update unless you are certain the native layer has not changed. A mismatch between the JS update and the native binary will crash the app. The fingerprint policy eliminates this class of error. If you must use a manual version, bump it every time you change native dependencies, add a config plugin, or upgrade the Expo SDK.

Rollback Strategy

# Roll back by publishing the previous branch state
npx eas-cli update --branch production --message "Rollback: revert checkout fix"

# Or point the channel to a different branch entirely
npx eas-cli channel:edit production --branch rollback-branch

EAS Update serves the most recent update on the branch. To roll back, either publish a new update with reverted code or redirect the channel to a known-good branch.

EAS Metadata

Manage App Store and Play Store listing metadata from code.

// store.config.json
{
  "configVersion": 0,
  "apple": {
    "info": {
      "en-US": {
        "title": "My App",
        "subtitle": "Tagline here",
        "description": "Full description...",
        "keywords": ["keyword1", "keyword2"],
        "privacyPolicyUrl": "https://example.com/privacy"
      }
    },
    "categories": ["UTILITIES"],
    "age_rating": {
      "CARTOON_FANTASY_VIOLENCE": "NONE"
    }
  },
  "android": {
    "title": { "en-US": "My App" },
    "short_description": { "en-US": "Short description" },
    "full_description": { "en-US": "Full description..." }
  }
}
# Push metadata to stores
npx eas-cli metadata:push

# Pull current metadata from stores into local config
npx eas-cli metadata:pull

Configuration

app.json vs app.config.ts

app.json is static configuration. app.config.ts is dynamic — it can read environment variables, compute values, and conditionally apply settings.

// app.config.ts
import type { ExpoConfig, ConfigContext } from 'expo/config';

export default ({ config }: ConfigContext): ExpoConfig => {
  const isProduction = process.env.APP_VARIANT === 'production';

  return {
    ...config,
    name: isProduction ? 'My App' : 'My App (Dev)',
    slug: 'my-app',
    version: '1.5.0',
    orientation: 'portrait',
    icon: './assets/icon.png',
    scheme: 'myapp',
    splash: {
      image: './assets/splash.png',
      resizeMode: 'contain',
      backgroundColor: '#ffffff',
    },
    ios: {
      supportsTablet: true,
      bundleIdentifier: isProduction
        ? 'com.example.myapp'
        : 'com.example.myapp.dev',
      infoPlist: {
        NSCameraUsageDescription: 'Camera access is needed for document scanning.',
      },
    },
    android: {
      adaptiveIcon: {
        foregroundImage: './assets/adaptive-icon.png',
        backgroundColor: '#ffffff',
      },
      package: isProduction
        ? 'com.example.myapp'
        : 'com.example.myapp.dev',
    },
    plugins: [
      'expo-router',
      [
        'expo-camera',
        { cameraPermission: 'Allow camera access for scanning.' },
      ],
      [
        'expo-build-properties',
        {
          ios: { deploymentTarget: '15.0' },
          android: { minSdkVersion: 24, compileSdkVersion: 34 },
        },
      ],
    ],
    extra: {
      eas: { projectId: 'your-project-id' },
    },
  };
};

Config Plugins

Config plugins modify the native project during npx expo prebuild — they are the mechanism that makes CNG possible. Instead of manually editing Info.plist or AndroidManifest.xml, you write (or use) a plugin that does it programmatically.

Built-In Plugin Usage

Most Expo SDK modules ship with their own config plugin. You configure them in the plugins array:

{
  "plugins": [
    ["expo-camera", { "cameraPermission": "We need camera for barcode scanning" }],
    ["expo-location", { "locationAlwaysAndWhenInUsePermission": "We track delivery routes" }],
    ["expo-notifications", { "icon": "./assets/notification-icon.png" }]
  ]
}

Writing a Custom Config Plugin

// plugins/withCustomSplash.ts
import { withInfoPlist, withAndroidManifest, type ConfigPlugin } from 'expo/config-plugins';

const withCustomSplash: ConfigPlugin<{ statusBarColor?: string }> = (
  config,
  { statusBarColor = '#ffffff' } = {},
) => {
  // Modify iOS Info.plist
  config = withInfoPlist(config, (modConfig) => {
    modConfig.modResults.UIStatusBarStyle = 'UIStatusBarStyleDarkContent';
    return modConfig;
  });

  // Modify Android AndroidManifest.xml
  config = withAndroidManifest(config, (modConfig) => {
    const mainApplication = modConfig.modResults.manifest.application?.[0];
    if (mainApplication) {
      mainApplication.$['android:theme'] = '@style/AppTheme';
    }
    return modConfig;
  });

  return config;
};

export default withCustomSplash;

Use it in your config:

// app.config.ts
plugins: [
  ['./plugins/withCustomSplash', { statusBarColor: '#0066CC' }],
],

Environment Variables

Expo supports environment variables with the EXPO_PUBLIC_ prefix for client-side access:

# .env.development
EXPO_PUBLIC_API_URL=https://api-dev.example.com
EXPO_PUBLIC_FEATURE_FLAGS_ENABLED=true

# .env.production
EXPO_PUBLIC_API_URL=https://api.example.com
EXPO_PUBLIC_FEATURE_FLAGS_ENABLED=false
// Access in code — available at build time, baked into the bundle
const apiUrl = process.env.EXPO_PUBLIC_API_URL;

EXPO_PUBLIC_ variables are embedded in the JS bundle and are fully visible to anyone who decompiles your app. Never put API secrets, signing keys, or credentials in EXPO_PUBLIC_ variables. Those belong on your server. Client-side environment variables are for configuration (API URLs, feature flags, analytics keys) — not secrets.

Upgrading

SDK Version Bumps

# Upgrade Expo SDK and all related packages
npx expo install expo@latest --fix

# Check for dependency issues after upgrade
npx expo-doctor

The --fix flag updates all Expo SDK packages to versions compatible with the new SDK version. Run npx expo-doctor afterward to catch version mismatches.

What Typically Breaks

AreaCommon issueMitigation
Config pluginsAPI changes in expo/config-pluginsCheck release notes; update custom plugins
Third-party native modulesIncompatible with new SDKCheck compatibility table before upgrading
Metro configCustom metro.config.js overridesPrefer extending defaults with getDefaultConfig
Deprecated modulesRenamed or removed SDK modulesFollow migration guides in changelogs
React Native versionMajor RN bumps (e.g., 0.76 to 0.78)Test on both platforms before merging

Prebuild Regeneration After Upgrade

After an SDK upgrade, regenerate native directories to pick up changes:

# Clean regeneration — removes old ios/ and android/
npx expo prebuild --clean

# Verify the build still works
npx eas-cli build --profile development --platform all --local

Testing Upgrades in CI

# CI step: upgrade, prebuild, build, run tests
npx expo install expo@latest --fix
npx expo-doctor
npx expo prebuild --clean
npx eas-cli build --profile development --platform ios --local --non-interactive

Run this in a branch before merging to main. Upgrade PRs should include both the dependency changes and any code fixes required.

Limitations

When Expo Is Not the Right Choice

  • Highly custom native code: apps with extensive VoIP, background Bluetooth LE, custom audio engines, or hardware-specific SDK integrations may outgrow config plugins. You can still use Expo, but you will end up in bare workflow managing native code directly.
  • Brownfield apps: embedding React Native into an existing native iOS/Android app is not supported by CNG or managed workflow. Use bare workflow with manual native integration.
  • Unsupported third-party SDKs: some vendor SDKs require manual linking steps, custom Gradle plugins, or Xcode build phases that no config plugin covers. Check the Expo config plugin registry and the SDK's documentation before committing.
  • Highly restricted enterprise environments: organizations that cannot use cloud builds (EAS) and do not want to self-host will miss key EAS benefits, though local builds remain fully functional.

Workarounds

LimitationWorkaround
Missing config plugin for a native SDKWrite a custom config plugin
Need to debug native code during developmentUse expo-dev-client (custom development build)
Config plugin cannot express the changeDrop to bare workflow for that one aspect; use expo prebuild and then commit the native dirs
Expo Go does not support a native moduleUse development builds (npx expo start --dev-client) instead of Expo Go

Performance Overhead

There is no meaningful performance overhead from using Expo. The Expo SDK modules compile to native code. The Expo runtime adds a thin initialization layer, but it does not sit between your app and React Native at render time. Benchmarks consistently show no measurable difference between Expo and bare React Native apps for equivalent functionality.

The one exception is Expo Go (the sandbox app for rapid iteration), which bundles many native modules you may not use, increasing binary size. Production builds include only the modules you import.

Anti-Patterns

Ejecting Instead of Using Config Plugins

The expo eject command was removed in SDK 46. If you find yourself wanting to "eject," you are actually looking for CNG with config plugins, or at most, bare workflow. Ejecting was a one-way operation that cut you off from Expo tooling — config plugins achieve the same native customization without that trade-off.

Committing ios/ and android/ in CNG Workflow

If you use CNG, the ios/ and android/ directories are generated artifacts. Committing them creates merge conflicts on every upgrade, defeats the purpose of CNG, and confuses the team about which is the source of truth (config or native files).

# .gitignore for CNG projects
ios/
android/

If you need to inspect or debug the generated native projects, run npx expo prebuild locally. If you need persistent native changes, either write a config plugin or switch to bare workflow.

Ignoring Runtime Version Pinning with EAS Update

Shipping OTA updates without proper runtime version pinning is the fastest way to crash your production app. If the JS bundle references a native module that does not exist in the installed binary, the app crashes on launch for every affected user.

Always use the fingerprint runtime version policy. If you use a manual string, treat it with the same discipline as a database migration version.

Using Deprecated Expo SDK Modules

The Expo team deprecates modules when better alternatives exist. Continuing to use deprecated modules means missing security patches and compatibility fixes.

DeprecatedReplacement
expo-app-loadingexpo-splash-screen
expo-randomexpo-crypto
expo-google-app-authexpo-auth-session
expo-permissionsPer-module permission APIs

Not Testing on Real Devices

Expo Go behavior can differ from production builds. Modules behave differently in the Expo Go sandbox vs a custom dev client. Always test with development builds on real devices before shipping.

# Build a development client for real-device testing
npx eas-cli build --profile development --platform ios

# Install on device and connect
npx expo start --dev-client

Skipping expo-doctor

After upgrading or adding new packages, always run the doctor:

npx expo-doctor

It catches version mismatches, deprecated packages, and misconfigured plugins before they surface as cryptic build failures.

On this page