Expo is the standard way to build React Native apps in 2026 — it provides a managed workflow, file-based routing with Expo Router, cloud-based EAS builds, and Over-The-Air updates. React Native Reanimated 3 enables 60fps animations running on the native thread. Claude Code generates Expo app screens, navigation layouts, native module integrations, push notification setup, offline storage patterns, and the EAS configuration for TestFlight and Play Store submission.
CLAUDE.md for Expo Projects
## Mobile Stack
- Expo SDK 52+ with React Native 0.76
- Routing: Expo Router v4 (file-based, works like Next.js App Router)
- State: Zustand + React Query (TanStack Query v5)
- Storage: expo-secure-store (sensitive), AsyncStorage (general), SQLite via expo-sqlite
- Animations: React Native Reanimated 3 + Gesture Handler 2
- Notifications: Expo Notifications + FCM (Android) / APNs (iOS)
- Build: EAS Build (no local Xcode/Android Studio needed)
- Environment: app.config.ts with expo-constants for env vars per build profile
- TypeScript: strict mode, no any
Expo Router File Structure
app/
├── _layout.tsx # Root layout (providers, fonts, auth guard)
├── (auth)/
│ ├── login.tsx # /login
│ └── register.tsx # /register
├── (tabs)/
│ ├── _layout.tsx # Tab bar layout
│ ├── index.tsx # / → Orders tab
│ ├── products.tsx # /products
│ └── profile.tsx # /profile
├── orders/
│ ├── [id].tsx # /orders/:id
│ └── new.tsx # /orders/new
└── +not-found.tsx # 404 screen
Root Layout with Auth Guard
// app/_layout.tsx
import { useEffect } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router';
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
import * as SplashScreen from 'expo-splash-screen';
import { useAuthStore } from '@/stores/auth';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/lib/queryClient';
SplashScreen.preventAutoHideAsync();
function AuthGuard({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuthStore();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (isLoading) return;
const inAuthGroup = segments[0] === '(auth)';
if (!user && !inAuthGroup) {
router.replace('/(auth)/login');
} else if (user && inAuthGroup) {
router.replace('/(tabs)/');
}
}, [user, isLoading, segments]);
return <>{children}</>;
}
export default function RootLayout() {
const [fontsLoaded] = useFonts({ Inter_400Regular, Inter_600SemiBold });
useEffect(() => {
if (fontsLoaded) SplashScreen.hideAsync();
}, [fontsLoaded]);
if (!fontsLoaded) return null;
return (
<QueryClientProvider client={queryClient}>
<AuthGuard>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" options={{ animation: 'fade' }} />
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="orders/[id]"
options={{ headerShown: true, title: 'Order Details', presentation: 'card' }}
/>
</Stack>
</AuthGuard>
</QueryClientProvider>
);
}
Tab Layout
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Platform } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#2563EB',
tabBarInactiveTintColor: '#6B7280',
tabBarStyle: {
paddingBottom: Platform.OS === 'ios' ? 20 : 5,
height: Platform.OS === 'ios' ? 85 : 60,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Orders',
tabBarIcon: ({ color, size }) => (
<Ionicons name="list-outline" color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="products"
options={{
title: 'Products',
tabBarIcon: ({ color, size }) => (
<Ionicons name="grid-outline" color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person-outline" color={color} size={size} />
),
}}
/>
</Tabs>
);
}
Orders Screen with React Query
// app/(tabs)/index.tsx
import { FlatList, RefreshControl, StyleSheet, View, Text, Pressable } from 'react-native';
import { Link } from 'expo-router';
import { useInfiniteQuery } from '@tanstack/react-query';
import { fetchOrders } from '@/api/orders';
export default function OrdersScreen() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
refetch,
isRefetching,
} = useInfiniteQuery({
queryKey: ['orders'],
queryFn: ({ pageParam }) => fetchOrders({ page: pageParam, limit: 20 }),
initialPageParam: 1,
getNextPageParam: (last, all) =>
last.hasMore ? all.length + 1 : undefined,
});
const orders = data?.pages.flatMap(p => p.items) ?? [];
return (
<FlatList
data={orders}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<Link href={`/orders/${item.id}`} asChild>
<Pressable style={styles.orderCard}>
<Text style={styles.orderId}>#{item.id.slice(-6)}</Text>
<Text style={styles.orderStatus}>{item.status}</Text>
<Text style={styles.orderAmount}>${(item.totalCents / 100).toFixed(2)}</Text>
</Pressable>
</Link>
)}
onEndReached={() => hasNextPage && fetchNextPage()}
onEndReachedThreshold={0.3}
ListFooterComponent={isFetchingNextPage ? <Text>Loading...</Text> : null}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} />
}
contentContainerStyle={styles.list}
/>
);
}
const styles = StyleSheet.create({
list: { padding: 16 },
orderCard: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
orderId: { fontFamily: 'Inter_600SemiBold', fontSize: 14, color: '#111' },
orderStatus: { fontSize: 13, color: '#6B7280' },
orderAmount: { fontFamily: 'Inter_600SemiBold', fontSize: 14, color: '#2563EB' },
});
Reanimated Swipe-to-Delete
// components/SwipeableOrderCard.tsx
import { GestureHandlerRootView, Swipeable } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, interpolate, Extrapolation } from 'react-native-reanimated';
function RightAction({ progress, onDelete }: { progress: Animated.SharedValue<number>; onDelete: () => void }) {
const animStyle = useAnimatedStyle(() => ({
transform: [{
translateX: interpolate(progress.value, [0, 1], [64, 0], Extrapolation.CLAMP),
}],
}));
return (
<Animated.View style={[styles.deleteAction, animStyle]}>
<Pressable onPress={onDelete} style={styles.deleteButton}>
<Ionicons name="trash-outline" size={20} color="#fff" />
</Pressable>
</Animated.View>
);
}
Push Notifications
// lib/notifications.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
export async function registerForPushNotifications(): Promise<string | null> {
if (!Device.isDevice) return null; // Simulator doesn't support push
const { status: existing } = await Notifications.getPermissionsAsync();
let finalStatus = existing;
if (existing !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') return null;
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const token = await Notifications.getExpoPushTokenAsync({ projectId });
return token.data; // Save to backend: await api.savePushToken(token.data)
}
For the TanStack Query patterns that manage server state in the orders screen, see the React Query guide for mutation and cache invalidation patterns. For offline storage and sync patterns for React Native, the mobile offline guide covers SQLite sync and conflict resolution. The Claude Skills 360 bundle includes React Native/Expo skill sets covering Expo Router, EAS builds, and push notification setup. Start with the free tier to try Expo screen generation.