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

TechnologyVersionPurpose
Expo54React Native framework
React Native0.81Cross-platform UI
React19.1UI library
Expo Router6File-based routing
NativeWind4Tailwind CSS for RN
TanStack Query5State & offline cache
Vibecode DB3.0Database client
lucide-react-native0.510Icon 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

GroupPurposeAccess
(auth)Authentication screensPublic (guests only)
(app)Main app screens with tabsProtected (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:

BackgroundTextPurpose
bg-backgroundtext-foregroundPage background
bg-cardtext-card-foregroundCards/containers
bg-primarytext-primary-foregroundPrimary actions
bg-mutedtext-muted-foregroundInputs, secondary
bg-destructive-Delete/danger
border-border-Borders

Theme colors are defined in theme.ts:

VariableLightDarkColor
backgroundlight purple tintdeep indigoPage background
foregrounddeep indigolight purplePrimary text
cardwhiteindigo-900Card backgrounds
primaryindigo-500indigo-400Primary actions
mutedslate-100indigo-700Secondary elements
bordergray-200indigo-700Borders

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 all

Development Commands

# Start dev server
yarn start
 
# Start with iOS simulator
yarn ios
 
# Start with Android emulator
yarn android
 
# Start web
yarn web

Next Steps