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
Native Rendering vs WebView: The Performance Gap
The most fundamental architectural difference between these three options is how they render UI. React Native and Expo both render to actual native platform views — the <Text> component renders a UILabel on iOS and a TextView on Android. Capacitor renders everything inside a WKWebView (iOS) or WebView (Android), which is a full browser engine embedded in a native shell.
This difference matters most for scroll performance and animations. Native list scrolling in React Native uses platform-native UIScrollView / RecyclerView, which the OS optimizes at the driver level — 60fps or 120fps ProMotion with consistent frame delivery. Capacitor's WebView uses the browser's scroll implementation, which has historically been 20-30ms behind native. Modern Chrome and WebKit have closed most of this gap, particularly on high-end devices, but the gap remains noticeable on mid-range Android hardware, which is often where your actual user base is concentrated in high-growth markets.
For apps where the primary interaction is reading content, displaying lists, and navigating between screens — the majority of business apps — the performance difference is rarely perceptible on recent hardware. For apps with custom gesture recognizers, complex animations, or highly interactive data-dense views, native rendering provides a meaningfully better baseline.
Capacitor's WebView has one genuine advantage: CSS animations using transform and opacity are GPU-composited by the browser engine and perform identically to native animations. If your UI is primarily CSS-driven (Tailwind, styled-components), Capacitor's rendering model fits naturally.
Expo vs Bare React Native: A Practical Distinction
Expo is not a separate framework from React Native — it is React Native with a curated set of native modules, a build system (EAS), and a router (Expo Router). The distinction between "Expo" and "React Native" in practice is the distinction between the Expo managed workflow and the bare workflow.
In the Expo managed workflow, Expo manages the native layer: you write JavaScript, declare which Expo SDK modules you need, and Expo handles the Xcode and Android Studio project. You cannot add arbitrary native modules that require custom native code — only those in the Expo SDK ecosystem. Most applications never need to go outside this ecosystem, and the tradeoff (simplified DX, EAS Build cloud infrastructure, OTA updates) is usually worth it.
The bare workflow gives you a full native project (an ios/ and android/ directory) with Expo SDK modules pre-installed. You can add any community native module, write custom native code in Swift/Kotlin, and build with the same tools as a non-Expo project. The difference from plain React Native is that you still have access to Expo's build tooling and SDK. Most projects that start in managed workflow and later need a custom native module migrate to bare workflow rather than abandoning Expo entirely.
Capacitor's Web-First Development Model
Capacitor's design premise is that the web codebase is primary. You build and test your application as a web application first — running in the browser during development — and then use Capacitor to deploy it to iOS and Android. This is architecturally different from React Native, where the mobile simulator is the primary development target.
The web-first model has a meaningful advantage for teams with existing web frontend expertise: the full web ecosystem (CSS libraries, web component libraries, browser DevTools) is available during development. A Vue or Angular team adding mobile support to their web app can use their existing component library, routing, and state management without learning React Native's component model.
The limitation of the web-first model appears when you need deeply platform-specific features: push notifications, background processing, native payments (Apple Pay / Google Pay), biometric authentication, or platform-specific gestures. Capacitor has plugins for many of these, but the plugin interface adds a serialization layer (native data must be serialized to JavaScript-compatible types). React Native's New Architecture (JSI) avoids this serialization overhead for custom native modules — JSI allows direct JavaScript-to-native function calls without JSON conversion.
App Store Submission and Code Signing
The operational burden of App Store and Google Play submission is a factor that affects framework choice more than most comparisons acknowledge. React Native bare workflow requires developers to have Xcode (macOS only) and Android Studio set up, manage code signing certificates and provisioning profiles manually, and understand the native build system well enough to troubleshoot failures. For teams without native mobile experience, this is a significant hidden cost.
Expo's EAS Submit handles App Store and Google Play submission from CI with minimal local configuration — you authenticate once, set credentials, and eas submit handles certificate management, app store API authentication, and binary upload. This eliminates the macOS requirement for iOS builds (EAS Build runs on Mac infrastructure in the cloud) and makes submission reproducible in CI pipelines. Capacitor returns to the Xcode/Android Studio requirement since it produces a standard native project — the web build is trivial, but the native wrapping and submission process is identical to any native mobile project.
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 →
Compare Expo and React Native package health on PkgPulse.
See also: React vs Vue and React vs Svelte, Best React Form Libraries (2026).