Skip to main content

Guide

React Native vs Expo vs Capacitor 2026

React Native, Expo, and Capacitor compared for cross-platform mobile in 2026. Native rendering vs WebView, OTA updates, native modules, and when to use each.

·PkgPulse Team·
0

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

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).

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.