Expo Router brings file-based routing to React Native — the app/ directory structure maps directly to navigation, the same way Next.js App Router works for web. app/(tabs)/index.tsx creates a tab screen; app/orders/[id].tsx creates a dynamic stack screen. Stack and Tabs layout components define navigation containers in _layout.tsx files. useLocalSearchParams<{ id: string }>() returns typed route params. useRouter() provides programmatic navigation. Protected routes throw redirects in _layout.tsx when the user isn’t authenticated. Deep links and universal links are configured in app.json with the scheme field. EAS Build compiles iOS and Android binaries in CI. Expo SDK provides Camera, Notifications, SecureStore, and 50+ other native APIs. Claude Code generates Expo Router app directory structures, layout files, typed navigation, authentication flows, and EAS Build configurations.
CLAUDE.md for Expo Router
## Expo Router Stack
- Version: expo >= 52, expo-router >= 4.0
- Structure: app/ directory — _layout.tsx for containers, index.tsx for screens
- Layouts: Stack (stack nav), Tabs (tab bar), Drawer — define in _layout.tsx
- Params: useLocalSearchParams<{ id: string }>() — typed from file name [id]
- Navigation: useRouter() — router.push/replace/back/navigate
- Auth: redirect in _layout.tsx beforeLoad or context provider check
- Deep links: scheme in app.json + app.config.ts for universal links
- EAS: eas build --platform all --profile production — in CI
App Directory Structure
app/
├── _layout.tsx ← Root layout (providers, theme)
├── (auth)/
│ ├── _layout.tsx ← Auth layout (redirect if already logged in)
│ ├── login.tsx
│ └── signup.tsx
├── (app)/
│ ├── _layout.tsx ← App layout (redirect if not logged in)
│ ├── (tabs)/
│ │ ├── _layout.tsx ← Tab bar layout
│ │ ├── index.tsx ← Home tab
│ │ ├── orders.tsx ← Orders tab
│ │ └── account.tsx ← Account tab
│ └── orders/
│ └── [id].tsx ← Order detail screen
└── +not-found.tsx ← 404
Root Layout
// app/_layout.tsx — root layout with providers
import { Stack } from "expo-router"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useColorScheme } from "react-native"
import { StatusBar } from "expo-status-bar"
import { AuthProvider } from "@/lib/auth"
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 2, staleTime: 60_000 },
},
})
export default function RootLayout() {
const colorScheme = useColorScheme()
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(app)" />
</Stack>
</AuthProvider>
</QueryClientProvider>
)
}
Authenticated App Layout
// app/(app)/_layout.tsx — redirect if not authenticated
import { Stack } from "expo-router"
import { Redirect } from "expo-router"
import { useAuth } from "@/lib/auth"
import { ActivityIndicator, View } from "react-native"
export default function AppLayout() {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) {
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
<ActivityIndicator />
</View>
)
}
if (!isAuthenticated) {
return <Redirect href="/login" />
}
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="orders/[id]"
options={{
title: "Order Details",
headerBackTitle: "Orders",
presentation: "card",
}}
/>
</Stack>
)
}
Tabs Layout
// app/(app)/(tabs)/_layout.tsx — bottom tab bar
import { Tabs } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
export default function TabsLayout() {
return (
<Tabs
screenOptions={({ route }) => ({
tabBarIcon: ({ color, size, focused }) => {
const icons: Record<string, keyof typeof Ionicons.glyphMap> = {
index: focused ? "home" : "home-outline",
orders: focused ? "receipt" : "receipt-outline",
account: focused ? "person" : "person-outline",
}
return <Ionicons name={icons[route.name] ?? "ellipse"} size={size} color={color} />
},
tabBarActiveTintColor: "#3b82f6",
tabBarStyle: { paddingBottom: 4 },
headerShown: false,
})}
>
<Tabs.Screen name="index" options={{ title: "Home" }} />
<Tabs.Screen name="orders" options={{ title: "Orders" }} />
<Tabs.Screen name="account" options={{ title: "Account" }} />
</Tabs>
)
}
Screens
// app/(app)/(tabs)/orders.tsx — orders list screen
import { FlatList, View, Text, Pressable, StyleSheet, RefreshControl } from "react-native"
import { useRouter } from "expo-router"
import { useQuery } from "@tanstack/react-query"
import { fetchOrders } from "@/api/orders"
import { useAuth } from "@/lib/auth"
export default function OrdersScreen() {
const router = useRouter()
const { userId } = useAuth()
const { data, isLoading, refetch, isRefetching } = useQuery({
queryKey: ["orders", userId],
queryFn: () => fetchOrders(userId!),
enabled: !!userId,
})
return (
<View style={styles.container}>
<Text style={styles.title}>Your Orders</Text>
<FlatList
data={data?.orders}
keyExtractor={item => item.id}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} />
}
renderItem={({ item }) => (
<Pressable
style={styles.orderItem}
onPress={() => router.push(`/orders/${item.id}`)}
>
<View>
<Text style={styles.orderId}>#{item.id.slice(-8)}</Text>
<Text style={styles.status}>{item.status}</Text>
</View>
<Text style={styles.total}>${(item.totalCents / 100).toFixed(2)}</Text>
</Pressable>
)}
ListEmptyComponent={
!isLoading ? <Text style={styles.empty}>No orders yet</Text> : null
}
/>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#fff", paddingHorizontal: 16 },
title: { fontSize: 28, fontWeight: "700", paddingTop: 60, paddingBottom: 16 },
orderItem: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#e5e7eb",
},
orderId: { fontSize: 16, fontWeight: "600" },
status: { fontSize: 13, color: "#6b7280", marginTop: 2 },
total: { fontSize: 16, fontWeight: "600" },
empty: { textAlign: "center", color: "#9ca3af", paddingTop: 48, fontSize: 16 },
})
// app/(app)/orders/[id].tsx — dynamic order detail screen
import { View, Text, ScrollView, StyleSheet } from "react-native"
import { useLocalSearchParams, Stack } from "expo-router"
import { useQuery } from "@tanstack/react-query"
import { fetchOrder } from "@/api/orders"
export default function OrderDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const { data: order, isLoading } = useQuery({
queryKey: ["orders", id],
queryFn: () => fetchOrder(id),
enabled: !!id,
})
return (
<>
<Stack.Screen options={{ title: `Order #${id?.slice(-8)}` }} />
<ScrollView style={styles.container}>
{order && (
<>
<View style={styles.section}>
<Text style={styles.label}>Status</Text>
<Text style={styles.value}>{order.status}</Text>
</View>
<View style={styles.section}>
<Text style={styles.label}>Total</Text>
<Text style={styles.value}>${(order.totalCents / 100).toFixed(2)}</Text>
</View>
{order.items.map(item => (
<View key={item.productId} style={styles.item}>
<Text>{item.name} × {item.quantity}</Text>
<Text>${((item.priceCents * item.quantity) / 100).toFixed(2)}</Text>
</View>
))}
</>
)}
</ScrollView>
</>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#fff" },
section: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: "#e5e7eb" },
label: { fontSize: 12, color: "#6b7280", textTransform: "uppercase", marginBottom: 4 },
value: { fontSize: 18, fontWeight: "600" },
item: { flexDirection: "row", justifyContent: "space-between", padding: 12, paddingHorizontal: 16 },
})
EAS Build Configuration
// eas.json — EAS Build profiles
{
"cli": { "version": ">= 12.0.0" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": { "simulator": true },
"android": { "buildType": "apk" }
},
"preview": {
"distribution": "internal",
"android": { "buildType": "apk" },
"ios": {
"enterpriseProvisioning": "adhoc"
}
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": {
"appleId": "[email protected]",
"ascAppId": "1234567890"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal"
}
}
}
}
# .github/workflows/eas-build.yml — CI with EAS
name: EAS Build
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "22" }
- run: npm ci
- uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: eas build --non-interactive --platform all --profile production
For the React Native CLI alternative when direct native code access without the Expo managed workflow is required — custom native modules, C++ JSI code, or dependencies that are not compatible with Expo’s SDK, see the React Native New Architecture guide for TurboModules and Fabric. For the Capacitor alternative that wraps web apps in a native WebView rather than using React Native’s native renderer — better when you want to ship an existing React web app as a mobile app without rewriting in React Native, see the Capacitor guide. The Claude Skills 360 bundle includes Expo Router skill sets covering navigation, auth flows, and EAS Build. Start with the free tier to try Expo Router configuration generation.