import ComparisonTable from ’../../components/ComparisonTable.astro’;
Expo and React Native CLI are two ways to build React Native apps. Expo is a framework and platform built on top of React Native that handles tooling, builds, and common native functionality. React Native CLI is the official bare-metal approach with full control over the native layer.
Quick Verdict
Choose Expo if: You’re starting a new app, value fast iteration, want OTA updates, or don’t need custom native modules that Expo doesn’t support.
Choose React Native CLI if: You need native modules not supported by Expo, have existing native iOS/Android code to integrate, or need complete control over the build pipeline.
Feature Comparison
<ComparisonTable headers={[“Dimension”, “Expo (Managed)”, “React Native CLI”]} rows={[ [“Setup time”, “Minutes”, “Hours (Xcode + Android Studio)”], [“iOS build without Mac”, “Yes (EAS Build)”, “No”], [“OTA updates”, “expo-updates built-in”, “react-native-code-push (3rd party)”], [“Native modules”, “SDK + custom plugins”, “Full access”], [“App size”, “Larger (SDK bundled)”, “Smaller (only what you add)”], [“Expo Go testing”, “Yes (instant device test)”, “No”], [“Web support”, “Yes (expo-web)”, “Partial (react-native-web)”], [“Push notifications”, “expo-notifications”, “Firebase directly”], [“EAS Build”, “Native integration”, “Supported”], [“Bare workflow”, “Yes (eject or start bare)”, “N/A (is the bare workflow)”], ]} />
Project Setup
Expo:
# Install Expo CLI
npm install -g expo-cli
# Create new project (Expo Router recommended in 2026)
npx create-expo-app MyApp --template
# Start development server
npx expo start
# Scan QR code with Expo Go app on your phone
# App loads on device immediately — no build needed
React Native CLI:
# Prerequisites: Xcode (Mac), Android Studio, JDK 17+
# Create project
npx react-native init MyApp
# iOS (Mac only)
cd MyApp
npx pod-install # Install CocoaPods dependencies
npx react-native run-ios
# Android
npx react-native run-android
# (requires Android emulator or physical device with USB debugging)
The setup difference is significant. React Native CLI requires configuring Xcode, Android Studio, CocoaPods, and environment variables. Expo requires only Node.js.
Project Structure
Expo (Expo Router file-based routing):
MyApp/
├── app/ # Expo Router — file = route
│ ├── (tabs)/
│ │ ├── _layout.tsx # Tab navigator
│ │ ├── index.tsx # Home tab
│ │ └── profile.tsx # Profile tab
│ ├── [id].tsx # Dynamic route
│ └── _layout.tsx # Root layout
├── components/
├── assets/
│ ├── fonts/
│ └── images/
├── constants/
├── hooks/
├── app.json # Expo config
└── eas.json # EAS Build config
React Native CLI:
MyApp/
├── android/ # Android native project
│ ├── app/
│ │ └── src/main/java/...
│ └── build.gradle
├── ios/ # iOS native project
│ ├── MyApp/
│ └── Podfile
├── src/
│ ├── screens/
│ ├── components/
│ ├── navigation/
│ └── store/
├── __tests__/
└── metro.config.js
React Native CLI exposes the full android/ and ios/ directories — you can edit them directly. Expo’s managed workflow hides them.
Native Module Integration
Expo — using Expo SDK modules:
// expo-camera — no native setup needed
import { CameraView, useCameraPermissions } from 'expo-camera';
export default function CameraScreen() {
const [permission, requestPermission] = useCameraPermissions();
if (!permission?.granted) {
return (
<Button title="Grant Camera Permission" onPress={requestPermission} />
);
}
return (
<CameraView style={{ flex: 1 }} facing="back" />
);
}
// expo-location
import * as Location from 'expo-location';
const { coords } = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.High,
});
// expo-notifications, expo-contacts, expo-biometrics...
// All work without Xcode/Android Studio configuration
Expo — using custom native module with Config Plugin:
// app.json — add Config Plugin (handles native setup automatically)
{
"expo": {
"plugins": [
["@config-plugins/react-native-bluetooth-classic", {
"bluetoothAlwaysPermission": "Allow $(PRODUCT_NAME) to use Bluetooth"
}]
]
}
}
// Then rebuild with EAS Build — plugin modifies native files
// npx eas build --platform all
React Native CLI — manual native module setup:
# Install library
npm install react-native-camera
# iOS: manually link
cd ios && pod install
# android/app/build.gradle — add dependencies manually
implementation project(':react-native-camera')
# android/settings.gradle
include ':react-native-camera'
project(':react-native-camera').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-camera/android')
# MainApplication.java — register package
packages.add(new RNCameraPackage());
React Native CLI requires manual native setup for each module. Expo Config Plugins automate this.
Building and Distribution
Expo — EAS Build:
# Install EAS CLI
npm install -g eas-cli
eas login
# Configure builds
eas build:configure
# Build iOS (no Mac required!)
eas build --platform ios
# Build Android
eas build --platform android
# Build both
eas build --platform all
# Submit directly to App Store / Play Store
eas submit --platform ios
eas submit --platform android
eas.json configuration:
{
"cli": { "version": ">= 7.0.0" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"ios": { "simulator": false }
},
"production": {
"ios": {
"resourceClass": "m-medium"
}
}
},
"submit": {
"production": {}
}
}
React Native CLI — manual build:
# iOS — Xcode required on Mac
cd ios
xcodebuild -workspace MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-archivePath build/MyApp.xcarchive \
archive
# Android — Gradle
cd android
./gradlew assembleRelease # APK
./gradlew bundleRelease # AAB (recommended for Play Store)
# Or use Fastlane for automation
Over-The-Air Updates
Expo Updates — built-in:
// app.json
{
"expo": {
"updates": {
"enabled": true,
"fallbackToCacheTimeout": 0,
"url": "https://u.expo.dev/your-project-id"
},
"runtimeVersion": { "policy": "appVersion" }
}
}
// In app — control update behavior
import * as Updates from 'expo-updates';
async function checkForUpdate() {
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync(); // Reload app with new JS
}
}
// Publish update (no App Store review needed)
// npx eas update --channel production
OTA updates let you push JavaScript changes without going through App Store review (native code changes still require a full release).
When to Choose Each
Choose Expo:
- New React Native project (no existing native code)
- Small team without mobile-native engineers
- Need to ship quickly — Expo eliminates environment setup
- Want OTA updates for fast iteration
- Building for web + iOS + Android from one codebase
- Don’t need obscure native modules (Expo SDK covers 90% of use cases)
Choose React Native CLI:
- Integrating into an existing native iOS or Android app
- Need a native module not available via Expo Config Plugins
- Advanced native performance requirements (games, custom camera)
- Team has strong native iOS/Android engineers
- Complete control over the build pipeline is required
The middle path — Expo bare workflow:
# Start with Expo but eject to bare workflow when needed
npx create-expo-app --template bare-minimum
# Or: Use Expo Router with bare workflow
# Gets file-based routing + EAS Build + OTA updates
# But exposes android/ and ios/ directories for native customization
Bottom Line
Expo has matured significantly — the “you’ll hit its limits” concern that made React Native CLI the safe default in 2019 applies to far fewer apps in 2026. Expo Router, EAS Build, and the Config Plugin system cover the vast majority of production app needs. Start with Expo; only switch to bare/CLI if you hit a specific native capability that Expo genuinely can’t handle.