Expo has matured to the point where you can build serious production apps without ever touching Xcode or Android Studio. Here's the modern workflow.
Setup
npx create-expo-app@latest MyApp --template tabs
cd MyApp
npx expo start
Scan the QR code with Expo Go on your phone to see it instantly. No build step for development.
File-Based Routing with Expo Router
app/
_layout.tsx # Root layout (fonts, auth gate, providers)
(tabs)/
_layout.tsx # Tab bar layout
index.tsx # Home tab
profile.tsx # Profile tab
auth/
login.tsx # /auth/login
signup.tsx # /auth/signup
modal.tsx # Modal screen
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
export default function TabLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: '#FCE38A' }}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) =>
<Ionicons name="home" size={size} color={color} />
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) =>
<Ionicons name="person" size={size} color={color} />
}}
/>
</Tabs>
)
}
Navigation
import { router, Link } from 'expo-router'
// Navigate programmatically
router.push('/profile')
router.push({ pathname: '/user/[id]', params: { id: '123' } })
router.back()
// Navigate via Link component
<Link href="/profile">Go to Profile</Link>
<Link href={{ pathname: '/user/[id]', params: { id: user.id } }}>
View Profile
</Link>
Native APIs
import * as Camera from 'expo-camera'
import * as Location from 'expo-location'
import * as ImagePicker from 'expo-image-picker'
// Camera permission and capture
async function takePhoto() {
const { status } = await Camera.requestCameraPermissionsAsync()
if (status !== 'granted') return
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.8,
allowsEditing: true,
aspect: [4, 3]
})
if (!result.canceled) {
uploadPhoto(result.assets[0].uri)
}
}
// Location
async function getCurrentLocation() {
const { status } = await Location.requestForegroundPermissionsAsync()
if (status !== 'granted') return null
const location = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced
})
return { lat: location.coords.latitude, lng: location.coords.longitude }
}
Push Notifications
import * as Notifications from 'expo-notifications'
import * as Device from 'expo-device'
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true
})
})
async function registerForPushNotifications(): Promise<string | null> {
if (!Device.isDevice) return null // Doesn't work in simulator
const { status } = await Notifications.getPermissionsAsync()
if (status !== 'granted') {
const { status: newStatus } = await Notifications.requestPermissionsAsync()
if (newStatus !== 'granted') return null
}
const token = await Notifications.getExpoPushTokenAsync({
projectId: process.env.EXPO_PUBLIC_PROJECT_ID
})
return token.data
}
// Send from your backend via Expo Push API
async function sendPushNotification(expoPushToken: string, title: string, body: string) {
await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: expoPushToken,
title,
body,
data: { screen: 'notifications' }
})
})
}
EAS Build for App Store Submission
# Install EAS CLI
npm install -g eas-cli
# Login and configure
eas login
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": {}
}
}
# Build for production
eas build --platform ios --profile production
eas build --platform android --profile production
# Submit to App Store / Play Store
eas submit --platform ios
eas submit --platform android
OTA Updates
The biggest advantage of Expo: deploy JavaScript updates without going through the App Store:
# Deploy an update instantly to all users
eas update --branch production --message "Fix checkout bug"
Native code changes still require a full build and store review. JavaScript/assets can update instantly.
The Expo ecosystem handles 90% of what mobile apps need. The 10% that requires native modules (custom camera filters, Bluetooth LE, specific hardware) usually has a community package. Use the bare workflow only if you're hitting that 10%.