Expo Integration
Building cross-platform mobile apps with Expo 54 and Expo Router 6. Covers file-based routing, theming, NativeWind styling, and deployment workflows.
Expo in AppLighter Apps
Every AppLighter app template 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 |
| React | 19.1 | UI library |
| Expo Router | 6 | File-based routing |
| NativeWind | 4 | Tailwind CSS for RN |
| TanStack Query | 5 | State & offline cache |
| Vibecode DB | 3.0 | Database client |
| lucide-react-native | 0.510 | Icon 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)Route Groups
| Group | Purpose | Access |
|---|---|---|
(auth) | Authentication screens | Public (guests only) |
(app) | Main app screens with tabs | 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, authReady, type User } from '../src/db/client'
function RootLayoutNav() {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const segments = useSegments()
const router = useRouter()
// Wait for auto-login to complete, then check session
useEffect(() => {
(async () => {
try {
await authReady
const { data } = await vibecode.auth.getSession()
setUser(data?.session?.user ?? null)
} catch (e) {
setUser(null)
} finally {
setIsLoading(false)
}
})()
}, [])
// Expose global auth helpers
useEffect(() => {
(globalThis as any).__setAuthUser = setUser
return () => { delete (globalThis as any).__setAuthUser }
}, [])
// Navigation guard
useEffect(() => {
if (isLoading) return
const isAuthenticated = !!user
const isAuthGroup = segments[0] === '(auth)'
if (!isAuthenticated && !isAuthGroup) {
router.replace('/(auth)/signin')
} else if (isAuthenticated && isAuthGroup) {
router.replace('/(app)')
}
}, [user, segments, isLoading])
return <Stack screenOptions={{ headerShown: false }} />
}
export default function RootLayout() {
return (
<SafeAreaProvider>
<AppProviders>
<RootLayoutNav />
</AppProviders>
</SafeAreaProvider>
)
}Tab Layout
Tab navigation for authenticated users with theme-aware styling:
// app/(app)/_layout.tsx
import { Tabs } from 'expo-router'
import { CheckSquare, User } from 'lucide-react-native'
import { cssInterop, useColorScheme } from 'nativewind'
cssInterop(CheckSquare, { className: { target: 'style', nativeStyleToProp: { color: true } } })
cssInterop(User, { className: { target: 'style', nativeStyleToProp: { color: true } } })
export default function TabsLayout() {
const { colorScheme } = useColorScheme()
const isDark = colorScheme === 'dark'
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: isDark ? '#818cf8' : '#6366f1',
tabBarInactiveTintColor: isDark ? '#a78bfa' : '#a5b4fc',
tabBarStyle: {
backgroundColor: isDark ? '#1e1b4b' : '#faf5ff',
borderTopColor: isDark ? '#4c1d95' : '#e9d5ff',
},
}}
>
<Tabs.Screen name="home" options={{
title: 'Tasks',
tabBarIcon: ({ focused }) => (
<CheckSquare className={focused ? 'text-primary' : 'text-muted-foreground'} size={24} />
),
}} />
<Tabs.Screen name="profile" options={{
title: 'Profile',
tabBarIcon: ({ focused }) => (
<User className={focused ? 'text-primary' : 'text-muted-foreground'} size={24} />
),
}} />
</Tabs>
)
}Styling with NativeWind
Use Tailwind CSS classes with the className prop on React Native primitives:
import { View, Text, Pressable } from 'react-native'
export function Card({ title, onPress }) {
return (
<Pressable
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>
</Pressable>
)
}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 |
Theme colors are defined in theme.ts:
| Variable | Light | Dark | Color |
|---|---|---|---|
background | light purple tint | deep indigo | Page background |
foreground | deep indigo | light purple | Primary text |
card | white | indigo-900 | Card backgrounds |
primary | indigo-500 | indigo-400 | Primary actions |
muted | slate-100 | indigo-700 | Secondary elements |
border | gray-200 | indigo-700 | Borders |
Dark Mode
The ThemeToggle component provides theme switching. Always include it in screen headers:
import { ThemeToggle } from '@/components/ThemeToggle'
// In your screen header:
<View className="flex-row items-center justify-between px-4 pt-4">
<Text className="text-2xl font-bold text-foreground">Home</Text>
<ThemeToggle />
</View>Use the useTheme hook for programmatic access:
import { useTheme } from '../src/hooks'
function MyComponent() {
const { isDark, colorScheme, toggleColorScheme } = useTheme()
// ...
}Screen Template
Standard screen with data fetching and refresh:
import { useCallback } from 'react'
import { View, Text, FlatList, RefreshControl } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { vibecode } from '../../src/db/client'
export default function HomeScreen() {
const queryClient = useQueryClient()
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 }) => (
<View className="bg-card mx-4 my-2 p-4 rounded-xl">
<Text className="text-foreground font-bold">{item.caption}</Text>
</View>
)}
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'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60 * 24, // 24 hours
},
},
})Building for Production
# Build for iOS
eas build --platform ios
# Build for Android
eas build --platform android
# Build for both
eas build --platform allDevelopment Commands
# Start dev server
yarn start
# Start with iOS simulator
yarn ios
# Start with Android emulator
yarn android
# Start web
yarn webNext Steps
- Explore Vibecode DB
- Explore UI Components