Expo has evolved into the recommended way to build React Native apps. With Expo SDK 52+, you get the best of both worlds: rapid development with managed workflow and full native access when needed. This guide covers building production apps with modern Expo.
Project Setup
# Create new Expo project
npx create-expo-app@latest my-app --template tabs
cd my-app
# Install additional dependencies
npx expo install expo-router expo-status-bar
npx expo install @react-native-async-storage/async-storage
npx expo install expo-secure-store
npx expo install react-native-reanimated react-native-gesture-handler
# Start development
npx expo start// app.json configuration
{
"expo": {
"name": "My App",
"slug": "my-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "myapp",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.company.myapp",
"buildNumber": "1"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.company.myapp",
"versionCode": 1
},
"plugins": [
"expo-router",
"expo-secure-store",
[
"expo-camera",
{ "cameraPermission": "Allow $(PRODUCT_NAME) to access camera" }
]
],
"experiments": {
"typedRoutes": true
}
}
}Expo Router Navigation
// File-based routing with Expo Router
// app/_layout.tsx
import { Stack } from 'expo-router';
import { ThemeProvider } from '@/providers/ThemeProvider';
import { AuthProvider } from '@/providers/AuthProvider';
export default function RootLayout() {
return (
<AuthProvider>
<ThemeProvider>
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="(auth)"
options={{ presentation: 'modal' }}
/>
<Stack.Screen
name="modal"
options={{ presentation: 'modal' }}
/>
</Stack>
</ThemeProvider>
</AuthProvider>
);
}
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@/hooks/useTheme';
export default function TabLayout() {
const { colors } = useTheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.text.secondary,
headerShown: true,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="search"
options={{
title: 'Search',
tabBarIcon: ({ color, size }) => (
<Ionicons name="search" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
// Type-safe navigation
import { router, useLocalSearchParams, Link } from 'expo-router';
// Navigate programmatically
router.push('/profile');
router.push({ pathname: '/product/[id]', params: { id: '123' } });
router.replace('/login');
router.back();
// Get route params
const { id } = useLocalSearchParams<{ id: string }>();
// Link component
<Link href="/settings" asChild>
<Pressable>
<Text>Settings</Text>
</Pressable>
</Link>State Management
// Zustand store with persistence
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshToken: () => Promise<void>;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isLoading: false,
login: async (email, password) => {
set({ isLoading: true });
try {
const response = await api.post('/auth/login', { email, password });
const { user, token } = response.data;
// Store token securely
await SecureStore.setItemAsync('token', token);
set({ user, token, isLoading: false });
} catch (error) {
set({ isLoading: false });
throw error;
}
},
logout: async () => {
await SecureStore.deleteItemAsync('token');
set({ user: null, token: null });
},
refreshToken: async () => {
const currentToken = get().token;
if (!currentToken) return;
const response = await api.post('/auth/refresh', { token: currentToken });
const { token } = response.data;
await SecureStore.setItemAsync('token', token);
set({ token });
},
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({ user: state.user }), // Only persist user
}
)
);
// React Query for server state
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: () => api.get('/products').then(r => r.data),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useCreateOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (order: CreateOrderDTO) => api.post('/orders', order),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['orders'] });
},
});
}EAS Build and Submit
// eas.json
{
"cli": {
"version": ">= 5.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": true
}
},
"preview": {
"distribution": "internal",
"ios": {
"simulator": false
},
"android": {
"buildType": "apk"
}
},
"production": {
"autoIncrement": true,
"ios": {
"resourceClass": "m1-medium"
},
"android": {
"buildType": "app-bundle"
}
}
},
"submit": {
"production": {
"ios": {
"appleId": "your@email.com",
"ascAppId": "1234567890",
"appleTeamId": "XXXXXXXXXX"
},
"android": {
"serviceAccountKeyPath": "./google-services.json",
"track": "internal"
}
}
}
}# Build commands
# Development build (with dev client)
eas build --profile development --platform ios
eas build --profile development --platform android
# Preview build for testing
eas build --profile preview --platform all
# Production build
eas build --profile production --platform all
# Submit to stores
eas submit --platform ios
eas submit --platform android
# OTA updates
eas update --branch production --message "Bug fixes"Best Practices
Expo Best Practices 2026
Development:
- Use Expo Router for file-based navigation
- Enable typed routes for type safety
- Use development builds for native testing
Performance:
- Use React Native Reanimated for animations
- Implement proper list virtualization
- Use Expo Image for optimized images
Deployment:
- Use EAS Build for CI/CD
- Implement OTA updates for quick fixes
- Use preview builds for QA testing
Conclusion
Expo has become the best way to build React Native apps in 2026. The combination of managed workflow convenience with full native access, plus EAS for builds and updates, provides a complete mobile development platform.
Need help building your mobile app? Contact Jishu Labs for expert React Native and Expo development.
About Riken Patel
Riken Patel is the Mobile Lead at Jishu Labs with extensive experience in React Native and cross-platform mobile development.