Expo Complete Guide | React Native Development Made Easy
이 글의 핵심
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)
1. 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
2. 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
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>
</>
)
}
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>
)
}
Navigation and routing
// 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>
)
}
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>
)
}
3. Common Native Features
Camera
npx expo install expo-camera
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>
)
}
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)
}
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')
4. 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,
}),
})
async function registerForPushNotifications() {
if (!Device.isDevice) {
console.log('Push notifications require a physical device')
return
}
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()
}
}, [])
}
5. 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' },
},
},
}
6. 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": {}
}
}
# 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
7. EAS Update — Over-The-Air Updates
Push JS-only updates without going through app stores:
# 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"
// 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
}
}
8. 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>
Expo vs Bare React Native
| Expo (managed) | Bare React Native | |
|---|---|---|
| Setup | Minutes | Hours |
| Native code | Via plugins | Full access |
| OTA updates | EAS Update | Manual (CodePush) |
| Build | EAS Build (cloud) | Local + CI |
| Ecosystem | Expo SDK | Full npm |
| When to use | Most apps | Custom native modules |
Key Takeaways
- Expo Router: file-based navigation — same mental model as Next.js
- Expo SDK: camera, location, notifications, secure storage without native setup
- EAS Build: build iOS without a Mac — critical for teams without Apple hardware
- EAS Update: push bug fixes and content updates without app store review delays
- Development builds: use when you need native modules not in Expo Go
- app.config.js: dynamic config with environment variables for API URLs and feature flags