Skip to main content

React Native vs Expo vs Capacitor: Cross-Platform Mobile Development (2026)

·PkgPulse Team

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 }
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

FeatureReact Native (Bare)ExpoCapacitor
RenderingNative viewsNative viewsWebView
PerformanceNativeNativeWeb (near-native)
Web frameworkReact onlyReact onlyAny (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 submitManual (Xcode)✅ (EAS Submit)Manual (Xcode)
TypeScript
Setup complexityHighLowLow
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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.