React Native with Expo: Expo Router, Push Notifications and EAS Build, Update and Submit

Key takeaways

Expo removes the hard parts of React Native — native builds, code signing, OTA updates — so you can focus on the app. This guide covers everything from project setup to deploying to both app stores with EAS.

Why Expo?

React Native requires Xcode, Android Studio, certificates, and build configuration. Expo abstracts all of that:

  • Expo Go: test on device instantly, no build needed
  • EAS Build: build iOS/Android in the cloud (no Mac needed for iOS)
  • EAS Update: push JS-only updates without app store review
  • Expo Router: file-based routing (like Next.js for mobile)
  • SDK: 50+ pre-built native modules (camera, location, notifications)

I set up a plain React Native project (no Expo) once specifically to understand what Expo actually saves you from, and the difference was stark within the first hour — signing certificates, provisioning profiles, and CocoaPods version conflicts ate most of a day before I’d even gotten a “Hello World” running on a physical iOS device. Expo’s actual value proposition isn’t “React Native but easier” in some vague sense, it’s specifically that it owns the entire native build toolchain on your behalf — you write JavaScript/TypeScript and native modules stay someone else’s problem (Expo’s) until the day you genuinely need custom native code that isn’t in the SDK, which for most apps (CRUD apps, content apps, most business apps) simply never happens.


Quick Start

# Create new project
npx create-expo-app@latest my-app
cd my-app

# Start development server
npx expo start

# Then press:
# i → iOS Simulator
# a → Android Emulator
# Scan QR code → Expo Go on physical device

Scanning the QR code to open your app inside Expo Go is worth understanding as more than a demo trick — it’s genuinely loading your actual JavaScript bundle over the network into a pre-built native shell app Expo already published to the App Store/Play Store, which is exactly what makes “instant” testing on a real device possible with zero build step. The tradeoff, covered more explicitly in the FAQ above, is that Expo Go only includes the native modules Expo itself ships with — the moment a project adds a native dependency Expo Go doesn’t bundle, that shortcut stops working and a development build (a custom-compiled version of that same shell, now including your specific native dependencies) becomes necessary instead.


Expo Router — File-Based Navigation

Expo Router is the recommended navigation system (replaces React Navigation boilerplate).

app/
  _layout.tsx        → root layout (tabs, drawer, stack)
  index.tsx          → / (home screen)
  (tabs)/
    _layout.tsx      → tab bar configuration
    index.tsx        → first tab
    settings.tsx     → second tab
  users/
    [id].tsx         → /users/123 (dynamic route)
  (auth)/
    login.tsx        → /login
    register.tsx     → /register

This is a deliberate, direct port of Next.js’s App Router conventions into a mobile context, and recognizing the parallel is worth doing explicitly if you’ve already worked with Next.js — file location determines route, [id].tsx means a dynamic segment, and grouping folders in parentheses ((tabs), (auth)) organize routes without adding a literal path segment, exactly the same convention Next.js uses for route groups. The practical payoff mirrors web development too: adding a new screen is “create a file,” not “register a new route in some central navigator config,” which is a meaningful reduction in the boilerplate that made React Navigation (the previous standard) feel heavier for anything beyond a small app.

Root layout

// app/_layout.tsx
import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'

export default function RootLayout() {
  return (
    <>
      <StatusBar style="auto" />
      <Stack>
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
        <Stack.Screen name="(auth)" options={{ headerShown: false }} />
        <Stack.Screen name="users/[id]" options={{ title: 'User Profile' }} />
      </Stack>
    </>
  )
}

_layout.tsx files are what make the folder structure actually mean something beyond routing — each one wraps every screen inside its folder with shared UI and navigation behavior, nesting the way the file tree nests. The root _layout.tsx here wraps the entire app in a Stack navigator (screens pushed/popped like a native stack, with back-swipe gestures for free), while the tab group gets its own nested _layout.tsx (next) that swaps in a completely different navigation paradigm — tabs — just for that subtree, without either layout needing to know about the other. headerShown: false on the (tabs) and (auth) groups here is deliberate: those groups define their own internal navigation chrome (a tab bar, or none at all for auth screens), so the outer stack’s default header would just be redundant UI stacked on top of it.

Tab layout

// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'

export default function TabLayout() {
  return (
    <Tabs screenOptions={{ tabBarActiveTintColor: '#007AFF' }}>
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="settings"
        options={{
          title: 'Settings',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="settings" size={size} color={color} />
          ),
        }}
      />
    </Tabs>
  )
}

The tab bar icon rendering as a function (({ color, size }) => ...) rather than a static element is worth noticing — it receives the current active/inactive state baked into color automatically, so the same icon component switches its tint without any manual “is this tab active” conditional logic in your own code; Expo Router (via React Navigation underneath) handles computing and passing the right color based on which tab is currently selected.

// app/index.tsx
import { Link, router } from 'expo-router'
import { View, Text, TouchableOpacity } from 'react-native'

export default function HomeScreen() {
  return (
    <View>
      {/* Declarative navigation */}
      <Link href="/settings">Settings</Link>
      <Link href="/users/123">User 123</Link>

      {/* Programmatic navigation */}
      <TouchableOpacity onPress={() => router.push('/settings')}>
        <Text>Go to Settings</Text>
      </TouchableOpacity>

      <TouchableOpacity onPress={() => router.replace('/(auth)/login')}>
        <Text>Logout</Text>
      </TouchableOpacity>
    </View>
  )
}

router.push versus router.replace is the distinction most worth internalizing here, since it changes what the back button/gesture does afterward — push adds a new screen onto the navigation stack, leaving the previous screen reachable by going back, which is the right choice for normal forward navigation (tapping into a detail screen). replace, used here for logout, swaps the current screen out of the stack entirely rather than stacking on top of it — critical for exactly this case, since you never want a logged-out user pressing back and landing on an authenticated screen they no longer have a valid session for; pushing to the login screen would leave that authenticated screen sitting right there in history, one back-gesture away.

Dynamic routes

// app/users/[id].tsx
import { useLocalSearchParams } from 'expo-router'
import { useEffect, useState } from 'react'

export default function UserScreen() {
  const { id } = useLocalSearchParams<{ id: string }>()
  const [user, setUser] = useState(null)

  useEffect(() => {
    fetchUser(id).then(setUser)
  }, [id])

  return (
    <View>
      <Text>User ID: {id}</Text>
      {user && <Text>{user.name}</Text>}
    </View>
  )
}

The generic type argument on useLocalSearchParams<{ id: string }>() is worth understanding as a type-level assertion, not a runtime validation — it tells TypeScript to treat id as a string, but Expo Router doesn’t actually check the URL’s shape against that type at runtime, so a route matched without the expected param genuinely returns undefined for id at runtime despite what the type claims. This is the same “declared type versus verified reality” gap covered in more depth in the Next.js/TypeScript guides elsewhere on this site — worth a defensive check (if (!id) return null) for any route where a missing param is a realistic possibility, rather than trusting the type annotation to guarantee something it can’t actually enforce.


Common Native Features

Camera

npx expo install expo-camera

npx expo install rather than plain npm install matters specifically for native Expo packages — it resolves the version compatible with your project’s exact Expo SDK version, rather than whatever the package registry considers “latest,” which can be genuinely incompatible with an older SDK. This is a real, recurring source of confusing native-module crashes for anyone who reaches for npm install expo-camera out of habit: the package installs fine, TypeScript compiles fine, and the app crashes or misbehaves at runtime on-device because the installed native module version doesn’t actually match what your SDK’s native runtime expects — expo install exists specifically to make that version-matching automatic rather than something you have to track manually.

import { CameraView, useCameraPermissions } from 'expo-camera'
import { useState } from 'react'

export default function CameraScreen() {
  const [permission, requestPermission] = useCameraPermissions()

  if (!permission?.granted) {
    return (
      <View>
        <Text>Camera access needed</Text>
        <Button title="Grant Permission" onPress={requestPermission} />
      </View>
    )
  }

  return (
    <CameraView style={{ flex: 1 }} facing="back">
      <View style={{ position: 'absolute', bottom: 40, alignSelf: 'center' }}>
        <Button title="Take Photo" onPress={() => {/* capture */}} />
      </View>
    </CameraView>
  )
}

The permission?.granted check with an early-return UI, rather than assuming access and handling a failure after the fact, is the pattern worth adopting as the default for every native permission in Expo — both iOS and Android can deny camera access at any time (the user revoking it in system settings mid-session, not just the initial prompt), and a component that assumes it always has access will crash or silently fail to render camera output the moment that assumption breaks. useCameraPermissions returning both the current permission state and a function to request it in one hook call is deliberately convenient for exactly this pattern — check state, render a request UI if needed, let the hook handle re-checking once the user responds to the system prompt.

Location

npx expo install expo-location
import * as Location from 'expo-location'

async function getCurrentLocation() {
  const { status } = await Location.requestForegroundPermissionsAsync()
  if (status !== 'granted') {
    console.log('Permission denied')
    return
  }

  const location = await Location.getCurrentPositionAsync({
    accuracy: Location.Accuracy.High,
  })

  console.log(location.coords.latitude, location.coords.longitude)
}

Every native-permission API in Expo follows this exact request/check shape deliberately, and it’s worth internalizing once rather than re-learning per-module — requestForegroundPermissionsAsync here mirrors requestPermissionsAsync in the Push Notifications section and useCameraPermissions above, all resolving to a status you’re expected to check before proceeding. Foreground in the function name is a real, meaningful distinction too: continuous background location tracking (an app that needs a user’s position even while closed) requires a separate, additional permission (requestBackgroundPermissionsAsync) and its own platform-level disclosure, both because it’s a materially bigger privacy concession and because app store review (both Apple and Google) scrutinizes background location access specifically and can reject an app that requests it without a clearly justified use case.

SecureStore (sensitive data)

npx expo install expo-secure-store
import * as SecureStore from 'expo-secure-store'

// Store auth token securely (encrypted on device)
await SecureStore.setItemAsync('authToken', token)

// Retrieve
const token = await SecureStore.getItemAsync('authToken')

// Delete
await SecureStore.deleteItemAsync('authToken')

SecureStore earns its keep specifically for data an attacker with physical device access shouldn’t be able to trivially extract — it’s backed by iOS Keychain and Android Keystore, the same hardware-backed encrypted storage system-level credentials use, which is categorically different from AsyncStorage (React Native’s plain key-value storage), stored as unencrypted plaintext on disk. An auth token, a refresh token, or any credential sitting in AsyncStorage is readable by anyone with filesystem access to a rooted/jailbroken device or a backup extraction tool — using SecureStore specifically for genuinely sensitive values (not app preferences, not cached non-sensitive data, where AsyncStorage’s simplicity and larger storage quota are the better fit) is a real, meaningful security boundary worth getting right rather than defaulting to whichever storage API is more familiar.


Push Notifications

npx expo install expo-notifications expo-device
import * as Notifications from 'expo-notifications'
import * as Device from 'expo-device'

// Configure notification behavior
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
})
// This handler specifically controls what happens when a notification
// arrives while the app is already open in the foreground — without it,
// a notification received while a user is actively using the app can
// silently do nothing visible at all, which is a genuinely easy gap to
// miss during testing (most manual testing happens with the app
// backgrounded, where the OS shows the notification automatically
// regardless of this handler). shouldShowAlert/shouldPlaySound/
// shouldSetBadge only govern the foreground case — background and killed-
// app notification presentation is handled entirely by the OS.

async function registerForPushNotifications() {
  if (!Device.isDevice) {
    console.log('Push notifications require a physical device')
    return
  }
  // This check exists because push notifications fundamentally require a
  // real APNs/FCM connection to Apple/Google's actual push infrastructure
  // — an iOS Simulator or Android Emulator has no genuine device identity
  // to register for push with, so requesting a token there either fails
  // outright or returns something non-functional. Skipping this check
  // is a common source of "push notifications don't work" confusion
  // during development specifically because everything else about the
  // code can look completely correct while testing exclusively on a
  // simulator, which is precisely where this feature can never actually
  // work regardless of how correct the implementation is.

  const { status: existingStatus } = await Notifications.getPermissionsAsync()
  let finalStatus = existingStatus

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync()
    finalStatus = status
  }

  if (finalStatus !== 'granted') {
    console.log('Push notification permission denied')
    return
  }

  // Get Expo push token
  const token = await Notifications.getExpoPushTokenAsync({
    projectId: 'your-project-id',  // from app.json
  })

  // Send token to your server
  await saveTokenToServer(token.data)

  return token
}

// Listen for notifications
function NotificationListener() {
  useEffect(() => {
    const sub1 = Notifications.addNotificationReceivedListener(notification => {
      console.log('Notification received:', notification)
    })

    const sub2 = Notifications.addNotificationResponseReceivedListener(response => {
      console.log('User tapped notification:', response)
      // Navigate based on notification data
      const data = response.notification.request.content.data
      if (data.screen) router.push(data.screen)
    })

    return () => {
      sub1.remove()
      sub2.remove()
    }
  }, [])
}

The two listeners cover genuinely different moments in a notification’s lifecycle, and conflating them is a common mistake — addNotificationReceivedListener fires when a notification arrives while the app is running (foreground or background) but before the user has done anything about it, useful for updating in-app state (a badge count, a data refresh) reactively. addNotificationResponseReceivedListener fires specifically when the user taps the notification, which is the deep-linking moment — routing them to the relevant screen based on the notification’s payload, as shown here. Cleaning up both subscriptions in the effect’s return function matters for the same reason any event listener needs cleanup: without it, remounting this component (navigating away and back) would register duplicate listeners, and a single incoming notification would eventually trigger the same navigation multiple times as listeners accumulate across remounts.


app.json / app.config.js

// app.config.js (dynamic config — preferred over app.json)
export default {
  expo: {
    name: 'My App',
    slug: 'my-app',
    version: '1.2.0',
    orientation: 'portrait',
    icon: './assets/icon.png',
    splash: {
      image: './assets/splash.png',
      resizeMode: 'contain',
      backgroundColor: '#ffffff',
    },
    ios: {
      bundleIdentifier: 'com.mycompany.myapp',
      buildNumber: '1',
      supportsTablet: true,
      infoPlist: {
        NSCameraUsageDescription: 'This app uses the camera to take photos.',
        NSLocationWhenInUseUsageDescription: 'This app uses your location to show nearby places.',
      },
    },
    android: {
      package: 'com.mycompany.myapp',
      versionCode: 1,
      adaptiveIcon: {
        foregroundImage: './assets/adaptive-icon.png',
        backgroundColor: '#ffffff',
      },
      permissions: ['ACCESS_FINE_LOCATION'],
    },
    plugins: [
      'expo-router',
      'expo-notifications',
      ['expo-camera', { cameraPermission: 'Allow $(PRODUCT_NAME) to access your camera.' }],
    ],
    extra: {
      apiUrl: process.env.API_URL ?? 'https://api.myapp.com',
      eas: { projectId: 'your-eas-project-id' },
    },
  },
}

app.config.js over static app.json is worth defaulting to for any real project, and the reason is right there in the extra.apiUrl line — a JS/TS config file can read process.env and branch on build-time conditions, letting the exact same config produce different output per environment (dev vs. staging vs. production API URLs, different bundle identifiers for a parallel dev-build install), where app.json’s plain JSON has no way to express that at all. The infoPlist/permissions entries deserve a specific callout too: those usage-description strings aren’t optional boilerplate — Apple’s App Store review explicitly checks that every requested permission has a genuine, specific, non-generic justification string, and a vague or missing description (NSCameraUsageDescription, etc.) is a real, common cause of App Store rejection that has nothing to do with the app’s actual functionality.


EAS Build — Cloud Builds

# Install EAS CLI
npm install -g eas-cli

# Login
eas login

# Configure
eas build:configure
// eas.json
{
  "cli": { "version": ">= 5.0.0" },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal",
      "ios": { "simulator": true }
    },
    "production": {
      "autoIncrement": true
    }
  },
  "submit": {
    "production": {}
  }
}

Each named profile here (development, preview, production) maps to a genuinely different kind of build artifact, not just different environment variables — development produces a debug-capable build with the dev client baked in (for the fast-refresh, inspect-anything workflow during active development), preview produces an internally-distributable build for QA/stakeholders to install without going through a store (and the simulator: true iOS override specifically produces a build installable on the Simulator rather than a real device, useful for quick QA without needing a physical iPhone), and production produces the actual store-submission artifact. Picking the wrong profile for the wrong purpose — building production for internal QA testing, say — works but wastes a build against your EAS quota and produces an artifact with production-level optimizations that make debugging a reported issue harder than it needs to be.

The “no Mac needed for iOS” claim in the FAQ above is worth being precise about what it actually means: EAS Build runs the entire iOS compilation pipeline (which genuinely requires macOS and Xcode) on Expo’s own cloud infrastructure, so you never need a physical Mac, but Apple’s own requirement that iOS apps be built on macOS tooling hasn’t gone away — it’s just someone else’s server running it. This is exactly why EAS Build was a big deal for cross-platform teams without Apple hardware, not because Apple relaxed a requirement, but because Expo abstracted away who has to own the Mac.

# Build for iOS (no Mac needed — runs in cloud)
eas build --platform ios --profile production

# Build for Android
eas build --platform android --profile production

# Build both
eas build --platform all --profile production

# Build development client (custom dev build)
eas build --platform all --profile development

Worth noting all four of these are genuinely long-running, real builds happening in Expo’s cloud (typically several minutes to tens of minutes depending on the profile and queue), not a local instant operation — the workflow this enables is closer to CI/CD than to a local dev-server hot-reload, and it’s typically triggered from a script or CI pipeline on a meaningful milestone (a release candidate, a nightly build) rather than something you’d want to run after every small code change during active development, which is exactly what the fast local Expo Go / dev-client loop from earlier sections is for instead.


EAS Update — Over-The-Air Updates

Push JS-only updates without going through app stores. The “JS-only” qualifier is the boundary worth understanding precisely, since it’s exactly where teams new to EAS Update hit a wall — it can ship a bug fix in your React/TypeScript code, a copy change, a new screen built entirely from existing native capabilities, but it cannot ship a genuinely new native module, a new permission requirement, or an updated native SDK version, because those require an actual recompiled binary submitted through the normal App Store/Play Store review process. Apple’s App Store guidelines also place real limits on this in spirit — an OTA update mechanism that lets an app’s fundamental purpose or feature set change without any App Store review is exactly the kind of thing Apple’s review guidelines exist to prevent, and Expo’s OTA updates are meant for bug fixes and JS-level content changes within an already-reviewed app’s scope, not as a way to bypass review for genuinely new functionality:

# Install expo-updates
npx expo install expo-updates

# Publish update to production channel
eas update --branch production --message "Fix login bug"

# Update specific platform
eas update --branch production --platform ios

# Preview updates before production
eas update --branch staging --message "New feature test"

Branches here are worth thinking of as independent update channels, not git branches in the version-control sense (a common point of confusion given the name) — a running app is built with a specific runtimeVersion and configured to check a specific channel, and eas update --branch staging publishes an update only reachable by installs actually pointed at that channel, which is exactly what makes staged rollouts and beta-testing separate populations of users possible without them ever needing a different app binary.

// Check for updates programmatically
import * as Updates from 'expo-updates'

async function checkForUpdates() {
  if (__DEV__) return  // skip in development

  const update = await Updates.checkForUpdateAsync()
  if (update.isAvailable) {
    await Updates.fetchUpdateAsync()
    await Updates.reloadAsync()  // restart with new code
  }
}

Updates.reloadAsync() restarting the app to apply the fetched update is worth understanding as a real, user-facing interruption to plan around, not an invisible background swap — a user actively mid-task when this fires experiences the app abruptly restarting under them, which is jarring if triggered without any consideration for when it happens. Most production apps check for and apply updates on cold start (before the user has started doing anything) rather than mid-session, or at minimum prompt the user (“An update is ready — restart now?”) rather than forcing an unannounced reload, which this bare example deliberately keeps minimal but a real app should build on top of.


EAS Submit — App Store Submission

# Submit iOS to App Store (requires Apple credentials)
eas submit --platform ios --latest

# Submit Android to Play Store
eas submit --platform android --latest

# Or submit specific build
eas submit --platform ios --id <build-id>

eas submit automates the mechanical parts of store submission — uploading the binary, filling in the required App Store Connect/Play Console metadata fields it can infer — but it’s worth being clear this doesn’t shortcut the parts that require a genuine human decision: screenshots, store description copy, age ratings, and crucially, Apple’s actual human review of the binary still happen exactly as they would for a manually-submitted app, and review can still take anywhere from hours to days and can still reject a submission for policy reasons entirely unrelated to how it was uploaded.


Expo vs Bare React Native

Expo (managed)Bare React Native
SetupMinutesHours
Native codeVia pluginsFull access
OTA updatesEAS UpdateManual (CodePush)
BuildEAS Build (cloud)Local + CI
EcosystemExpo SDKFull npm
When to useMost appsCustom native modules