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