Expo Integration
Building cross-platform mobile apps with Expo 54 and Expo Router 6.
Expo in AppLighter
AppLighter is built on Expo 54 with React Native 0.81, providing a managed workflow for building iOS, Android, and web apps from a single codebase.
Tech Stack
| Technology | Version | Purpose | |------------|---------|---------| | Expo | 54 | React Native framework | | React Native | 0.81 | Cross-platform UI | | Expo Router | 6 | File-based routing | | gluestack-ui | 3 | Copy-paste UI components | | NativeWind | 4 | Tailwind CSS for RN | | TanStack Query | 5 | State & offline cache | | React | 19.1 | UI library |
File-Based Routing
Routes are defined by the file structure in app/:
app/
├── _layout.tsx # Root layout with auth state
├── index.tsx # Entry (redirects based on auth)
├── (auth)/ # Public routes (guests)
│ ├── _layout.tsx # Auth layout wrapper
│ ├── signin.tsx # /signin
│ └── signup.tsx # /signup
└── (app)/ # Protected routes (authenticated)
├── _layout.tsx # Tab navigation layout
├── home.tsx # /home (tab)
├── profile.tsx # /profile (tab)
├── create-post.tsx # /create-post
└── storage.tsx # /storage (tab)
Route Groups
| Group | Purpose | Access |
|-------|---------|--------|
| (auth) | Authentication screens | Public (guests only) |
| (app) | Main app screens | Protected (requires login) |
Root Layout
The root layout manages auth state and redirects:
// app/_layout.tsx
import { useEffect, useState } from 'react'
import { Stack, useRouter, useSegments } from 'expo-router'
import { vibecode } from '../src/db/client'
export default function RootLayout() {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const segments = useSegments()
const router = useRouter()
// Make setter available globally for auth updates
useEffect(() => {
(globalThis as any).__setAuthUser = setUser
}, [])
// Check session on mount
useEffect(() => {
vibecode.auth.getSession().then(({ data }) => {
setUser(data?.user ?? null)
setIsLoading(false)
})
}, [])
// Handle redirects based on auth state
useEffect(() => {
if (isLoading) return
const inAuthGroup = segments[0] === '(auth)'
if (!user && !inAuthGroup) {
router.replace('/signin')
} else if (user && inAuthGroup) {
router.replace('/home')
}
}, [user, segments, isLoading])
return <Stack screenOptions={{ headerShown: false }} />
}
Tab Layout
Tab navigation for authenticated users:
// app/(app)/_layout.tsx
import { Tabs } from 'expo-router'
import { Home, User, FolderOpen, PlusCircle } from 'lucide-react-native'
import { useTheme } from '../../src/hooks'
export default function AppLayout() {
const { isDark } = useTheme()
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: isDark ? '#fff' : '#000',
tabBarStyle: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
borderTopColor: isDark ? '#333' : '#e5e5e5',
},
}}
>
<Tabs.Screen
name="home"
options={{
title: 'Home',
tabBarIcon: ({ color }) => <Home size={24} color={color} />,
}}
/>
<Tabs.Screen
name="create-post"
options={{
title: 'Create',
tabBarIcon: ({ color }) => <PlusCircle size={24} color={color} />,
}}
/>
<Tabs.Screen
name="storage"
options={{
title: 'Files',
tabBarIcon: ({ color }) => <FolderOpen size={24} color={color} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color }) => <User size={24} color={color} />,
}}
/>
</Tabs>
)
}
Styling with NativeWind
Use Tailwind CSS classes with the className prop:
import { View, Text, TouchableOpacity } from 'react-native'
export function Card({ title, onPress }) {
return (
<TouchableOpacity
className="bg-card rounded-xl mx-4 my-2 p-4 border border-border"
onPress={onPress}
>
<Text className="text-foreground font-bold text-lg">{title}</Text>
</TouchableOpacity>
)
}
Theme Variables
Use semantic theme variables for consistent styling:
| Background | Text | Purpose |
|------------|------|---------|
| bg-background | text-foreground | Page background |
| bg-card | text-card-foreground | Cards/containers |
| bg-primary | text-primary-foreground | Primary actions |
| bg-muted | text-muted-foreground | Inputs, secondary |
| bg-destructive | - | Delete/danger |
| border-border | - | Borders |
Dark Mode
Use the dark: prefix or useTheme hook:
// Using className prefix
<View className="bg-white dark:bg-zinc-900">
<Text className="text-black dark:text-white">Hello</Text>
</View>
// Using hook
import { useTheme } from '../src/hooks'
function MyComponent() {
const { isDark, toggleTheme } = useTheme()
return (
<View className={isDark ? 'bg-black' : 'bg-white'}>
<Button onPress={toggleTheme}>Toggle Theme</Button>
</View>
)
}
Screen Template
Standard screen with data fetching and refresh:
import { useCallback } from 'react'
import { View, FlatList, RefreshControl } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../../src/hooks'
import { vibecode } from '../../src/db/client'
export default function HomeScreen() {
const queryClient = useQueryClient()
const { user } = useAuth()
const query = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const { data, error } = await vibecode
.from('posts')
.order('created_at', { ascending: false })
.limit(50)
.select('*')
if (error) throw error
return data ?? []
},
})
const handleRefresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
}, [queryClient])
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<FlatList
data={query.data}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <PostCard post={item} />}
refreshControl={
<RefreshControl
refreshing={query.isFetching}
onRefresh={handleRefresh}
/>
}
/>
</SafeAreaView>
)
}
Offline Support
TanStack Query is configured for offline persistence:
// src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import AsyncStorage from '@react-native-async-storage/async-storage'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60 * 24, // 24 hours
},
},
})
export const persister = createAsyncStoragePersister({
storage: AsyncStorage,
})
Building for Production
# Build for iOS
eas build --platform ios
# Build for Android
eas build --platform android
# Build for both
eas build --platform all
Development Commands
# Start dev server
pnpm dev:mobile
# Start with iOS simulator
cd apps/mobile && expo start --ios
# Start with Android emulator
cd apps/mobile && expo start --android
# Start web
cd apps/mobile && expo start --web
Next Steps
- Learn about the Hono API Layer
- Explore Vibecode DB
- Set up AI Generation