React Native vs Expo vs Capacitor: Cross-Platform Mobile Development (2026)
TL;DR
React Native is the cross-platform framework by Meta — write React components that render to native iOS and Android views, access native APIs, large ecosystem, used by Instagram, Shopify, Discord. Expo is the React Native platform — managed workflow, EAS Build/Submit/Update, Expo Router, pre-built native modules, the recommended way to build React Native apps in 2026. Capacitor is the web-to-native bridge by Ionic — wrap any web app (React, Vue, Angular) in a native container, access native APIs via plugins, progressive migration from web to mobile. In 2026: Expo for most React Native apps, bare React Native for deep native integration, Capacitor for wrapping existing web apps.
Key Takeaways
- React Native: react-native ~2M weekly downloads — native rendering, JS bridge, large ecosystem
- Expo: expo ~1.5M weekly downloads — managed workflow, EAS, OTA updates, Expo Router
- Capacitor: @capacitor/core ~200K weekly downloads — web → native, any web framework, plugins
- Expo is now the recommended starting point for React Native projects
- Capacitor lets you use your existing web app codebase
- React Native renders to actual native views, Capacitor renders a WebView
React Native (Bare)
React Native — native mobile with React:
Basic component
import { View, Text, StyleSheet, TouchableOpacity, FlatList } from "react-native"
import { useState, useEffect } from "react"
interface Package {
name: string
downloads: number
version: string
}
function PackageList() {
const [packages, setPackages] = useState<Package[]>([])
useEffect(() => {
fetch("https://api.pkgpulse.com/packages")
.then((res) => res.json())
.then(setPackages)
}, [])
return (
<View style={styles.container}>
<Text style={styles.title}>Top Packages</Text>
<FlatList
data={packages}
keyExtractor={(item) => item.name}
renderItem={({ item }) => (
<TouchableOpacity style={styles.card}>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.downloads}>
{item.downloads.toLocaleString()} downloads/week
</Text>
<Text style={styles.version}>v{item.version}</Text>
</TouchableOpacity>
)}
/>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, backgroundColor: "#0f172a" },
title: { fontSize: 24, fontWeight: "bold", color: "#fff", marginBottom: 16 },
card: { backgroundColor: "#1e293b", padding: 16, borderRadius: 8, marginBottom: 8 },
name: { fontSize: 18, fontWeight: "600", color: "#3b82f6" },
downloads: { fontSize: 14, color: "#94a3b8", marginTop: 4 },
version: { fontSize: 12, color: "#64748b", marginTop: 2 },
})
Native modules
// ios/NativeModules/PackageAnalyzer.swift
import Foundation
@objc(PackageAnalyzer)
class PackageAnalyzer: NSObject {
@objc func analyze(_ name: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
// Native iOS code...
resolve(["name": name, "score": 95])
}
}
// Usage in JS:
import { NativeModules } from "react-native"
const { PackageAnalyzer } = NativeModules
const result = await PackageAnalyzer.analyze("react")
console.log(result) // { name: "react", score: 95 }
Navigation (React Navigation)
import { NavigationContainer } from "@react-navigation/native"
import { createNativeStackNavigator } from "@react-navigation/native-stack"
const Stack = createNativeStackNavigator()
function App() {
return (
<NavigationContainer>
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: "#0f172a" },
headerTintColor: "#fff",
}}
>
<Stack.Screen name="Home" component={PackageList} />
<Stack.Screen name="Details" component={PackageDetails} />
<Stack.Screen name="Compare" component={CompareView} />
</Stack.Navigator>
</NavigationContainer>
)
}
Expo
Expo — the React Native platform:
Create and start
# Create new project:
npx create-expo-app@latest pkgpulse-mobile
cd pkgpulse-mobile
# Start development:
npx expo start
# → Scan QR code with Expo Go app
# → Or press 'i' for iOS simulator, 'a' for Android emulator
Expo Router (file-based routing)
// app/_layout.tsx
import { Stack } from "expo-router"
export default function Layout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: "#0f172a" },
headerTintColor: "#fff",
}}
/>
)
}
// app/index.tsx
import { Link } from "expo-router"
import { View, Text, FlatList, Pressable } from "react-native"
export default function Home() {
return (
<View style={{ flex: 1, padding: 16 }}>
<FlatList
data={packages}
renderItem={({ item }) => (
<Link href={`/package/${item.name}`} asChild>
<Pressable style={{ padding: 16 }}>
<Text>{item.name}</Text>
</Pressable>
</Link>
)}
/>
</View>
)
}
// app/package/[name].tsx
import { useLocalSearchParams } from "expo-router"
export default function PackageDetail() {
const { name } = useLocalSearchParams<{ name: string }>()
// Fetch and display package details...
}
EAS Build and Submit
# Install EAS CLI:
npm install -g eas-cli
# Configure:
eas build:configure
# Build for iOS:
eas build --platform ios --profile production
# Build for Android:
eas build --platform android --profile production
# Submit to App Store:
eas submit --platform ios
# Submit to Google Play:
eas submit --platform android
// eas.json
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": {
"appleId": "royce@example.com",
"ascAppId": "123456789"
},
"android": {
"serviceAccountKeyPath": "./google-services.json",
"track": "production"
}
}
}
}
OTA Updates
# Push over-the-air update (no app store review):
eas update --branch production --message "Fix download count formatting"
# → Users get the update on next app launch
# → No App Store/Google Play review needed
# → JS bundle updates only (not native code)
Capacitor
Capacitor — web to native:
Add to existing web app
# Install Capacitor:
npm install @capacitor/core @capacitor/cli
# Initialize:
npx cap init "PkgPulse" "com.pkgpulse.app"
# Add platforms:
npm install @capacitor/ios @capacitor/android
npx cap add ios
npx cap add android
# Build web app then sync:
npm run build
npx cap sync
capacitor.config.ts
import { CapacitorConfig } from "@capacitor/cli"
const config: CapacitorConfig = {
appId: "com.pkgpulse.app",
appName: "PkgPulse",
webDir: "dist",
server: {
androidScheme: "https",
},
plugins: {
SplashScreen: {
launchAutoHide: true,
backgroundColor: "#0f172a",
},
StatusBar: {
style: "dark",
backgroundColor: "#0f172a",
},
PushNotifications: {
presentationOptions: ["badge", "sound", "alert"],
},
},
}
export default config
Native plugins
import { Camera, CameraResultType } from "@capacitor/camera"
import { Share } from "@capacitor/share"
import { Haptics, ImpactStyle } from "@capacitor/haptics"
import { LocalNotifications } from "@capacitor/local-notifications"
import { Preferences } from "@capacitor/preferences"
// Camera:
const photo = await Camera.getPhoto({
quality: 90,
resultType: CameraResultType.Uri,
allowEditing: true,
})
console.log(photo.webPath)
// Share:
await Share.share({
title: "Check out react on PkgPulse",
url: "https://pkgpulse.com/packages/react",
dialogTitle: "Share Package",
})
// Haptics:
await Haptics.impact({ style: ImpactStyle.Medium })
// Local notifications:
await LocalNotifications.schedule({
notifications: [{
title: "Package Update",
body: "react@19.0.1 is available!",
id: 1,
schedule: { at: new Date(Date.now() + 5000) },
}],
})
// Preferences (key-value storage):
await Preferences.set({ key: "theme", value: "dark" })
const { value } = await Preferences.get({ key: "theme" })
Use with any framework
// React:
import { useEffect, useState } from "react"
import { App as CapApp } from "@capacitor/app"
import { Capacitor } from "@capacitor/core"
function App() {
const [isNative] = useState(Capacitor.isNativePlatform())
useEffect(() => {
if (!isNative) return
// Handle back button (Android):
CapApp.addListener("backButton", ({ canGoBack }) => {
if (canGoBack) {
window.history.back()
} else {
CapApp.exitApp()
}
})
// Handle app URL opens (deep links):
CapApp.addListener("appUrlOpen", ({ url }) => {
const slug = url.split("pkgpulse.com").pop()
if (slug) router.push(slug)
})
}, [isNative])
return <RouterProvider router={router} />
}
Feature Comparison
| Feature | React Native (Bare) | Expo | Capacitor |
|---|---|---|---|
| Rendering | Native views | Native views | WebView |
| Performance | Native | Native | Web (near-native) |
| Web framework | React only | React only | Any (React, Vue, Angular) |
| File-based routing | ❌ (manual) | ✅ (Expo Router) | ❌ (web router) |
| OTA updates | ❌ (manual) | ✅ (EAS Update) | ✅ (Appflow) |
| Cloud builds | ❌ | ✅ (EAS Build) | ✅ (Appflow) |
| Native modules | ✅ (manual bridge) | ✅ (Expo Modules) | ✅ (plugins) |
| Camera/GPS/etc. | ✅ (community) | ✅ (expo-camera) | ✅ (@capacitor/*) |
| Existing web app | ❌ (rewrite) | ❌ (rewrite) | ✅ (wrap existing) |
| Hot reload | ✅ | ✅ | ✅ (web) |
| App Store submit | Manual (Xcode) | ✅ (EAS Submit) | Manual (Xcode) |
| TypeScript | ✅ | ✅ | ✅ |
| Setup complexity | High | Low | Low |
| Web output | ❌ | ✅ (web support) | ✅ (is a web app) |
When to Use Each
Use React Native (bare) if:
- Need deep native integration with custom native modules
- Want full control over the native build process
- Building performance-critical apps with heavy native code
- Already have native iOS/Android developers on the team
Use Expo if:
- Starting a new React Native project (recommended default)
- Want managed builds, OTA updates, and app store submission
- Need file-based routing (Expo Router)
- Don't need custom native modules (or can use Expo Modules)
Use Capacitor if:
- Have an existing web app to deploy as a mobile app
- Use Vue, Angular, or another web framework (not just React)
- Want to share a single codebase for web + mobile
- Building apps where WebView performance is acceptable
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on React Native v0.76.x, Expo SDK 52, and Capacitor v6.x.
Compare mobile development and frontend libraries on PkgPulse →