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:
| Layer | What it provides |
|---|---|
| Expo SDK | A curated set of cross-platform native modules (camera, file system, notifications, etc.) with a unified JS API |
| Expo CLI / Toolchain | npx 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 startIdeal 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-minimumIdeal 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 iosAfter 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
| Aspect | Managed | CNG (Prebuild) | Bare |
|---|---|---|---|
| Native code in repo | No | Optional (can gitignore) | Yes, committed |
| Native customization | Config plugins only | Config plugins + inspect generated output | Direct native code |
| Upgrade cost | Low (Expo handles) | Low (regenerate) | High (manual merge) |
| CI complexity | Low | Medium | High |
| Debugging native crashes | Limited (EAS logs) | Full (Xcode/AS after prebuild) | Full |
| Third-party native SDKs | Must have config plugin | Config plugin or patch post-prebuild | Direct integration |
| Team native expertise needed | None | Minimal | Significant |
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 routeRoot 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
| Aspect | Expo Router | React Navigation |
|---|---|---|
| Route definition | File-system convention | Explicit JS configuration |
| Deep linking | Automatic | Manual linking config |
| Type safety | Inferred from file names | Manual ParamList types |
| Web support | Built-in | Experimental |
| Migration cost | Requires app/ directory restructure | Works with any project structure |
| Flexibility | Convention-driven | Maximum control |
| Maturity | Newer, rapidly evolving | Battle-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
| Module | Replaces / Provides | Notes |
|---|---|---|
expo-image | react-native-fast-image, RN <Image> | Disk + memory cache, blurhash, transitions, SVG, animated formats |
expo-camera | react-native-camera | Camera preview, barcode scanning, photo/video capture |
expo-file-system | react-native-fs | Read/write files, download/upload with progress |
expo-notifications | react-native-push-notification | Local + remote push, categories, scheduling |
expo-secure-store | react-native-keychain | Keychain (iOS) / Keystore (Android) for small secrets |
expo-location | react-native-geolocation | Foreground + background location, geofencing |
expo-haptics | react-native-haptic-feedback | Haptic feedback patterns |
expo-splash-screen | react-native-splash-screen | Splash screen management with preventAutoHideAsync |
expo-updates | CodePush | OTA JS bundle updates with channels and rollback |
expo-av | react-native-video, react-native-sound | Audio/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-mapsfor 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
| Aspect | Expo Modules API | TurboModules (Codegen) |
|---|---|---|
| Languages | Swift + Kotlin | Obj-C/C++ + Java/Kotlin |
| Spec definition | Swift/Kotlin DSL | TypeScript spec file |
| Lifecycle | Expo module lifecycle hooks | RN application lifecycle |
| Complexity | Lower (declarative) | Higher (Codegen, C++ glue) |
| Expo dependency | Requires expo package | Works without Expo |
| Use case | Expo-based projects | Framework-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-analyticsSwift 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 --localLocal 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-devEAS 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 --autoRuntime 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-branchEAS 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:pullConfiguration
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-doctorThe --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
| Area | Common issue | Mitigation |
|---|---|---|
| Config plugins | API changes in expo/config-plugins | Check release notes; update custom plugins |
| Third-party native modules | Incompatible with new SDK | Check compatibility table before upgrading |
| Metro config | Custom metro.config.js overrides | Prefer extending defaults with getDefaultConfig |
| Deprecated modules | Renamed or removed SDK modules | Follow migration guides in changelogs |
| React Native version | Major 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 --localTesting 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-interactiveRun 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
| Limitation | Workaround |
|---|---|
| Missing config plugin for a native SDK | Write a custom config plugin |
| Need to debug native code during development | Use expo-dev-client (custom development build) |
| Config plugin cannot express the change | Drop to bare workflow for that one aspect; use expo prebuild and then commit the native dirs |
| Expo Go does not support a native module | Use 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.
| Deprecated | Replacement |
|---|---|
expo-app-loading | expo-splash-screen |
expo-random | expo-crypto |
expo-google-app-auth | expo-auth-session |
expo-permissions | Per-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-clientSkipping expo-doctor
After upgrading or adding new packages, always run the doctor:
npx expo-doctorIt catches version mismatches, deprecated packages, and misconfigured plugins before they surface as cryptic build failures.