If you know React and TypeScript, you already know 80% of what you need to build a real mobile app. Expo closes the remaining gap — file-based routing, native module abstractions, cloud builds, and OTA updates without going through App Store review.
Setup
npx create-expo-app@latest my-app --template blank-typescript
cd my-app
npx expo start
Scan the QR code with Expo Go to preview instantly. No Xcode or Android Studio needed during development.
Versions: Expo SDK 52, React Native 0.76, New Architecture enabled by default.
Expo Router: File-Based Routing
Same conventions as Next.js App Router:
app/
_layout.tsx ← root Stack navigator
index.tsx ← /
(tabs)/
_layout.tsx ← Tab navigator
index.tsx ← first tab
profile.tsx ← /profile
(auth)/
login.tsx ← /login (outside tabs)
posts/
[id].tsx ← /posts/:id
+not-found.tsx
// app/_layout.tsx
import { Stack } from 'expo-router'
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)/login" options={{ presentation: 'modal' }} />
</Stack>
)
}
// app/posts/[id].tsx
import { useLocalSearchParams, useRouter, Stack } from 'expo-router'
import { View, Text, Pressable } from 'react-native'
export default function PostDetail() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
return (
<>
<Stack.Screen options={{ title: `Post ${id}` }} />
<View style={{ flex: 1, padding: 16 }}>
<Text style={{ fontSize: 24, fontWeight: 'bold' }}>Post {id}</Text>
<Pressable onPress={() => router.back()}>
<Text>Go Back</Text>
</Pressable>
</View>
</>
)
}
Web vs React Native: The Key Differences
| Web | React Native |
|---|---|
<div> |
<View> |
<p>, <span>
|
<Text> (all text must be in Text) |
<img> |
<Image> (needs explicit width/height) |
map() for lists |
<FlatList> (virtualized) |
<button> |
<Pressable> |
<input> |
<TextInput> |
| CSS | StyleSheet or NativeWind |
| localStorage | SecureStore (encrypted) |
No DOM, no CSS cascade, no window. Flexbox is the default layout system — you don't write display: flex.
Data Fetching with TanStack Query
Identical API to web:
npm install @tanstack/react-query
// hooks/usePosts.ts
import { useQuery } from '@tanstack/react-query'
export function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('https://api.yourapp.com/posts')
if (!res.ok) throw new Error('Failed to fetch')
return res.json()
},
staleTime: 1000 * 60 * 5
})
}
// FlatList with pull-to-refresh
import { FlatList, RefreshControl, ActivityIndicator } from 'react-native'
import { usePosts } from '~/hooks/usePosts'
export default function HomeScreen() {
const { data: posts, isLoading, refetch, isRefetching } = usePosts()
if (isLoading) return <ActivityIndicator size="large" color="#6366f1" />
return (
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <PostCard post={item} />}
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
/>
)
}
FlatList renders only viewport items — always use it over map() for potentially long lists.
Secure Auth with SecureStore
Never use AsyncStorage for tokens — it's not encrypted:
npx expo install expo-secure-store
// lib/auth.ts
import * as SecureStore from 'expo-secure-store'
import { router } from 'expo-router'
const TOKEN_KEY = 'auth_token'
export const saveToken = (token: string) => SecureStore.setItemAsync(TOKEN_KEY, token)
export const getToken = () => SecureStore.getItemAsync(TOKEN_KEY)
export const deleteToken = () => SecureStore.deleteItemAsync(TOKEN_KEY)
export async function logout() {
await deleteToken()
router.replace('/(auth)/login')
}
Protect your tabs layout:
// app/(tabs)/_layout.tsx
import { Redirect } from 'expo-router'
import { useAuth } from '~/hooks/useAuth'
export default function TabLayout() {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) return null
if (!isAuthenticated) return <Redirect href="/(auth)/login" />
return <Tabs>{/* ... */}</Tabs>
}
Forms with React Hook Form + Zod
Exact same library as web:
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { TextInput, Pressable, KeyboardAvoidingView, Platform } from 'react-native'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
type LoginForm = z.infer<typeof loginSchema>
export default function LoginScreen() {
const { control, handleSubmit, formState: { errors } } = useForm<LoginForm>({
resolver: zodResolver(loginSchema)
})
return (
// KeyboardAvoidingView prevents keyboard from covering inputs on iOS
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<Controller
control={control}
name="email"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
placeholder="Email"
keyboardType="email-address"
autoCapitalize="none"
onBlur={onBlur}
onChangeText={onChange}
value={value}
/>
)}
/>
<Pressable onPress={handleSubmit(onSubmit)}>
<Text>Log in</Text>
</Pressable>
</KeyboardAvoidingView>
)
}
Styling: StyleSheet vs NativeWind
StyleSheet (built-in):
import { StyleSheet } from 'react-native'
const styles = StyleSheet.create({
card: {
backgroundColor: '#f9fafb',
borderRadius: 12,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
elevation: 3 // Android shadow
}
})
NativeWind (Tailwind classes on native):
npm install nativewind tailwindcss
import { styled } from 'nativewind'
import { View, Text } from 'react-native'
const StyledView = styled(View)
const StyledText = styled(Text)
function Card({ title }: { title: string }) {
return (
<StyledView className="bg-white rounded-xl p-4 mb-3 shadow-sm">
<StyledText className="text-lg font-semibold text-gray-900">{title}</StyledText>
</StyledView>
)
}
EAS: Production Builds and OTA Updates
npm install -g eas-cli
eas login
eas build:configure
eas build --platform all --profile production
eas submit --platform all
OTA updates — push JS changes without App Store review:
eas update --branch production --message "Fix login bug"
Users get the update next time they open the app. Only native code changes (new Expo modules, SDK upgrades) require a new store submission.
Push Notifications
npx expo install expo-notifications expo-device
import * as Notifications from 'expo-notifications'
import * as Device from 'expo-device'
export async function registerForPushNotifications(): Promise<string | null> {
if (!Device.isDevice) return null
const { status } = await Notifications.requestPermissionsAsync()
if (status !== 'granted') return null
const token = await Notifications.getExpoPushTokenAsync({
projectId: process.env.EXPO_PUBLIC_PROJECT_ID!
})
return token.data // send to your backend
}
The Mental Model
React Native gives you native UI components — not a WebView. A <View> becomes a UIView on iOS and android.view.View on Android. The New Architecture (stable in RN 0.76) replaced the async JS bridge with synchronous JSI calls — performance is no longer a valid objection.
The libraries you already use — Zustand, TanStack Query, React Hook Form, Zod — work identically. The surface area that's genuinely different (StyleSheet, FlatList, KeyboardAvoidingView, SecureStore) is learnable in a week.
Full article at stacknotice.com/blog/expo-react-native-complete-guide-2026
Top comments (0)