Building Production Mobile Apps with React Native and Expo: Architecture, Navigation, and OTA Updates
A practical guide to React Native and Expo architecture decisions for production in 2026: project structure, navigation patterns, state management, native modules, OTA updates with EAS Update, and CI/CD with EAS Build.
The first thing most teams get wrong with React Native is treating it like a web project that runs on a phone. The bundler looks familiar, the JSX looks familiar, and TypeScript works the same way. So teams make the same structural decisions they would for a Next.js app, then spend the next six months fighting the gaps.
Production mobile has its own constraints: you cannot hot-reload a crash in the App Store, OTA update windows are measured in days not seconds, native modules require platform-specific build pipelines, and the review process sits between you and your users. This guide covers the architecture decisions that matter before your first EAS Build, not after your second rejected submission.
Project Structure
The default Expo template is a reasonable starting point, but production apps benefit from an explicit feature-based layout rather than the type-based layout (screens/, components/, hooks/) that most tutorials show.
src/
features/
auth/
screens/
LoginScreen.tsx
SignUpScreen.tsx
hooks/
useAuth.ts
api/
authApi.ts
types.ts
feed/
screens/
FeedScreen.tsx
PostDetailScreen.tsx
hooks/
useFeed.ts
api/
feedApi.ts
shared/
components/
Button.tsx
Card.tsx
ErrorBoundary.tsx
hooks/
useTheme.ts
useNetworkStatus.ts
lib/
api.ts
storage.ts
analytics.ts
navigation/
RootNavigator.tsx
types.ts
app/
(tabs)/
index.tsx
profile.tsx
_layout.tsx
The features/ directory collocates everything related to a domain. When you remove a feature, you remove one directory. When you onboard a new engineer, they can read one feature folder to understand the pattern.
The app/ directory at the root is where Expo Router lives. More on that split shortly.
Navigation: Expo Router vs React Navigation
This is the first genuine architectural decision, and the answer depends on whether your routing model is primarily file-based or programmatic.
Expo Router uses file-system routing identical in concept to Next.js App Router. Files in app/ become screens. Nested folders become nested navigators. Dynamic segments use [id].tsx syntax. It handles deep linking and web rendering (if you target web) automatically.
// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: "#0ea5e9",
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: "Feed",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
tabBarIcon: ({ color, size }) => (
<Ionicons name="person-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
React Navigation is the manual approach. You define your navigator tree in TypeScript, which gives you complete control over the structure and strong typing for route params.
// navigation/types.ts
export type RootStackParamList = {
Auth: undefined;
Main: undefined;
};
export type AuthStackParamList = {
Login: undefined;
SignUp: { referralCode?: string };
};
export type MainTabParamList = {
Feed: undefined;
Profile: { userId: string };
};
// navigation/RootNavigator.tsx
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import type { RootStackParamList } from "./types";
const Stack = createNativeStackNavigator<RootStackParamList>();
export function RootNavigator() {
const { isAuthenticated } = useAuth();
return (
<NavigationContainer>
<Stack.Navigator screenOptions={{ headerShown: false }}>
{isAuthenticated ? (
<Stack.Screen name="Main" component={MainTabNavigator} />
) : (
<Stack.Screen name="Auth" component={AuthStackNavigator} />
)}
</Stack.Navigator>
</NavigationContainer>
);
}
The practical difference: Expo Router is faster to set up and handles deep links out of the box. React Navigation gives you stronger TypeScript inference for route params, especially when you have complex nested stacks with shared screens. If you are building a straightforward app with standard tab/stack navigation, use Expo Router. If your navigation tree is complex or you need fine-grained control over transitions and guards, React Navigation is worth the extra setup.
State Management
The React Native ecosystem has the same state management choices as web. The patterns that work there work here, with one additional constraint: serializable state matters more because you often need to persist it to AsyncStorage or SecureStore.
For most apps, the combination of React Query (now TanStack Query) for server state and Zustand for client state covers everything without ceremony.
// shared/lib/api.ts
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
});
// features/auth/hooks/useAuth.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
interface AuthState {
token: string | null;
userId: string | null;
setToken: (token: string, userId: string) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
userId: null,
setToken: (token, userId) => set({ token, userId }),
clearAuth: () => set({ token: null, userId: null }),
}),
{
name: "auth-storage",
storage: createJSONStorage(() => AsyncStorage),
}
)
);
Avoid Redux unless you have a genuine need for the devtools or time-travel debugging. The boilerplate cost is real and the mobile development loop is slow enough that complexity compounds quickly.
Native Module Integration
Expo’s managed workflow handles most common native requirements through its SDK. Camera, location, notifications, biometrics, file system, and secure storage all work without ejecting. This is the right place to start.
When you need a module that Expo does not provide, you have two paths: install a community module that supports Expo (check the Expo compatibility list), or use the bare workflow and write a native module.
For custom native code without full ejection, Expo supports Config Plugins. These are functions that modify the native project files during the build step, letting you add native dependencies while keeping your workflow managed.
// app.config.ts
import type { ExpoConfig } from "expo/config";
const config: ExpoConfig = {
name: "MyApp",
slug: "myapp",
version: "1.0.0",
platforms: ["ios", "android"],
plugins: [
"expo-camera",
"expo-secure-store",
[
"expo-notifications",
{
icon: "./assets/notification-icon.png",
color: "#0ea5e9",
sounds: ["./assets/notification.wav"],
},
],
// Custom config plugin for a third-party SDK
"./plugins/withCustomSdk",
],
ios: {
bundleIdentifier: "com.company.myapp",
infoPlist: {
NSCameraUsageDescription: "Used to scan QR codes",
},
},
android: {
package: "com.company.myapp",
permissions: ["CAMERA"],
},
};
export default config;
The Config Plugin system is what keeps the managed workflow viable for production apps. Write a plugin once, and the native project is always reproducible from source.
OTA Updates with EAS Update
Over-the-air updates are the biggest operational difference between mobile and web. In web, you deploy and all users see the update within seconds. In mobile, the App Store review process and update adoption curves mean you are always managing multiple live versions.
EAS Update lets you push JavaScript bundle updates without going through app store review. The constraint is that you cannot change native code this way. Any new native module, permission, or SDK version requires a new binary.
# Install and configure
npm install expo-updates
eas update:configure
# Push an update to a channel
eas update --channel production --message "Fix feed pagination bug"
# Push to a staging channel first
eas update --channel staging --message "Test new onboarding flow"
The channel system maps to your deployment environments. A common setup:
| Channel | Who sees it | When to use |
|---|---|---|
| development | Local dev builds only | Feature development |
| preview | Internal testers | QA before staging |
| staging | Beta users | Smoke test before production |
| production | All users | Stable releases only |
In code, you can check the current update channel and version:
import * as Updates from "expo-updates";
export async function checkForUpdate(): Promise<boolean> {
if (__DEV__) return false;
try {
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
return true;
}
return false;
} catch (error) {
// Log but do not crash — OTA failure should not block the user
console.error("OTA check failed:", error);
return false;
}
}
// In your root component or app initializer
export function useOTAUpdate() {
useEffect(() => {
checkForUpdate().then((updated) => {
if (updated) {
// Reload at a safe moment, not immediately
// Give the user a chance to finish what they're doing
Alert.alert(
"Update available",
"Restart the app to apply a new update.",
[{ text: "Restart", onPress: () => Updates.reloadAsync() }]
);
}
});
}, []);
}
Do not call Updates.reloadAsync() immediately on update. Interrupting a user mid-flow is a worse experience than delaying the update. Prompt them and let them choose.
The runtime version in your app.config.ts controls which binary versions are compatible with a given update. Increment it only when you ship new native code. If your runtime version and the update’s runtime version do not match, the update will not be applied to that binary.
// app.config.ts (runtime version strategy)
const config: ExpoConfig = {
// ...
runtimeVersion: {
policy: "sdkVersion", // or "nativeVersion" or a fixed string
},
};
sdkVersion is the lowest friction option: runtime version tracks your Expo SDK version. Upgrade the SDK, increment the runtime version, and ship a new binary. Between SDK upgrades, all OTA updates are compatible.
CI/CD with EAS Build
EAS Build is a managed build service for React Native. It handles the provisioning profiles, signing certificates, and Gradle configurations that have historically consumed days of engineer time.
A minimal eas.json for a production workflow:
{
"cli": {
"version": ">= 7.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development"
},
"preview": {
"distribution": "internal",
"channel": "preview",
"ios": {
"simulator": false
}
},
"production": {
"autoIncrement": true,
"channel": "production"
}
},
"submit": {
"production": {
"ios": {
"appleId": "your-apple-id@company.com",
"ascAppId": "1234567890",
"appleTeamId": "ABCDEFGHIJ"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal"
}
}
}
}
For GitHub Actions, a typical production pipeline:
# .github/workflows/production.yml
name: Production Build
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Build iOS
run: eas build --platform ios --profile production --non-interactive
- name: Build Android
run: eas build --platform android --profile production --non-interactive
- name: Submit to stores
run: eas submit --platform all --profile production --non-interactive
The --non-interactive flag is required in CI. Without it, EAS Build will hang waiting for input that will never come.
Secrets management: EAS has its own secret store. Use it for anything build-time (signing keys, API keys baked into the binary). Do not check credentials into the repository.
eas secret:create --scope project --name SENTRY_DSN --value "https://..."
eas secret:create --scope project --name API_BASE_URL --value "https://api.company.com"
These are then available as environment variables during the EAS Build process.
Tradeoffs: Managed Workflow vs Bare Workflow
| Dimension | Managed Workflow | Bare Workflow | When to switch |
|---|---|---|---|
| Native code access | Via Config Plugins only | Full access | Custom native module needed |
| Build complexity | Handled by EAS | You own Xcode and Gradle | Almost never worth it early |
| Upgrade path | expo upgrade | Manual native migration | Managed is strictly easier |
| Community modules | Expo-compatible subset | Any React Native module | Rare edge cases |
| Time to first build | Hours | Days to weeks | Always start managed |
| Reproducible builds | Yes (Config Plugins) | Yes (if disciplined) | Managed is more reliable |
The practical advice: start managed, stay managed as long as possible. Bare workflow is not a more advanced version of managed, it is a different cost structure. The escape hatch exists for the cases where you genuinely need it.
Production Considerations
Error boundaries and crash reporting. React Native does not have the same error boundary behavior as the web. Native crashes bypass the JS thread entirely. Integrate Sentry or Crashlytics for native crash reporting separately from your JS error boundaries.
// shared/components/ErrorBoundary.tsx
import * as Sentry from "@sentry/react-native";
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface State {
hasError: boolean;
eventId: string | null;
}
export class ErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false, eventId: null };
static getDerivedStateFromError(): Partial<State> {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
const eventId = Sentry.captureException(error, {
extra: { componentStack: info.componentStack },
});
this.setState({ eventId });
}
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<View>
<Text>Something went wrong.</Text>
<TouchableOpacity
onPress={() => this.setState({ hasError: false, eventId: null })}
>
<Text>Try again</Text>
</TouchableOpacity>
</View>
)
);
}
return this.props.children;
}
}
Offline handling. Mobile users lose connectivity in ways web users rarely do. Check network status before mutations and queue failed requests. The @react-native-community/netinfo package exposes connection state; TanStack Query’s networkMode: "offlineFirst" handles the queue.
App Store review. iOS review averages 24-48 hours but can take longer for new apps or major version changes. Build this into your release timeline. Submit to Google Play first (usually faster) and use the internal test track to smoke test before promoting to production.
Build numbers. autoIncrement: true in eas.json handles build number increments automatically. Do not try to manage these manually across iOS and Android. They have different semantics (build number vs version code) and manual management causes submission failures.
Closing
The Expo managed workflow in 2026 covers the majority of production mobile requirements without the overhead of maintaining native build toolchains. The inflection points where you need more are real but narrow: custom hardware integrations, modules with no Expo-compatible version, and apps with strict build reproducibility requirements that need full control over the native project.
Start with managed. Ship with EAS Build. Use OTA updates to iterate on JavaScript changes between native releases. Invest the time you save on build infrastructure into error monitoring and offline handling, which are the production issues that actually affect users.
The mobile release cycle is longer than web. The operational model needs to match it, not fight it.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.