Mobile with Expo
The GospeLib mobile app (apps/mobile) is built with Expo SDK 52 and React Native 0.76. This guide covers Expo Router, offline-first architecture, and development workflow.
Architecture
- Framework: Expo SDK 52 / React Native 0.76
- Routing: Expo Router (file-based)
- State: Zustand stores for client state
- Offline: SQLite for local content + MMKV for key-value storage
- API:
@gospelib/sdkfor server communication - React: React 18.3 (Expo 52 constraint — not React 19)
Project Structure
apps/mobile/
├── app/
│ ├── _layout.tsx # Root layout (providers, theme)
│ ├── (tabs)/ # Tab navigator
│ │ ├── _layout.tsx # Tab bar configuration
│ │ ├── index.tsx # Home tab
│ │ ├── reader.tsx # Scripture reader tab
│ │ ├── search.tsx # Search tab
│ │ └── profile.tsx # Profile tab
│ ├── (auth)/ # Auth flow
│ │ ├── _layout.tsx
│ │ ├── sign-in.tsx
│ │ └── sign-up.tsx
│ ├── passage/
│ │ └── [id].tsx # Dynamic passage route
│ └── topic/
│ └── [id].tsx # Dynamic topic route
├── assets/ # Static assets (images, fonts)
├── app.json # Expo configuration
├── eas.json # EAS Build configuration
├── metro.config.js # Metro bundler configuration
└── package.json
Expo Router
Expo Router uses file-based routing similar to Next.js:
Tab Navigator
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen name="index" options={{ title: 'Home' }} />
<Tabs.Screen name="reader" options={{ title: 'Reader' }} />
<Tabs.Screen name="search" options={{ title: 'Search' }} />
<Tabs.Screen name="profile" options={{ title: 'Profile' }} />
</Tabs>
);
}
Dynamic Routes
// app/passage/[id].tsx
import { useLocalSearchParams } from 'expo-router';
export default function PassageScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
// id = "gen.1.1"
return <PassageView passageId={id} />;
}
Navigation
import { router } from 'expo-router';
// Navigate to a passage
router.push(`/passage/${passageId}`);
// Go back
router.back();
Offline-First Architecture
The mobile app is designed to work without an internet connection. Scripture content is synced to the device for offline reading.
SQLite for Content
Use expo-sqlite for local scripture storage:
import * as SQLite from 'expo-sqlite';
const db = SQLite.openDatabaseSync('gospelib.db');
// Query offline passages
const passages = db.getAllSync('SELECT * FROM passages WHERE book_id = ? AND chapter = ?', [
bookId,
chapter,
]);
MMKV for Key-Value Storage
Use react-native-mmkv for fast key-value storage (preferences, tokens, small state):
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
// Store user preference
storage.set('theme', 'dark');
// Read preference
const theme = storage.getString('theme');
Sync Strategy
- On first launch, download the core content set (passages, lexicon basics)
- Sync additional content in the background when connected
- Read from local SQLite first; fall back to API if not cached
- Queue writes (highlights, notes) when offline; sync when reconnected
Zustand Stores
State management uses Zustand for simplicity and performance:
import { create } from 'zustand';
interface ReaderStore {
currentPassageId: string | null;
fontSize: number;
setPassage: (id: string) => void;
setFontSize: (size: number) => void;
}
export const useReaderStore = create<ReaderStore>((set) => ({
currentPassageId: null,
fontSize: 16,
setPassage: (id) => set({ currentPassageId: id }),
setFontSize: (size) => set({ fontSize: size }),
}));
Shared UI Components
The mobile app uses cross-platform components from @gospelib/ui:
import { ScriptureText, WordToken } from '@gospelib/ui';
export function PassageView({ passage }) {
return (
<ScriptureText
text={passage.text}
words={passage.words}
onWordPress={(word) => router.push(`/lexicon/${word.strongs}`)}
/>
);
}
Components from @gospelib/ui use React Native primitives (View, Text, Pressable) which work on both mobile and web via React Native Web.
Development Workflow
Start the dev server
cd apps/mobile
npx expo start
Run on a device or simulator
# iOS simulator
npx expo run:ios
# Android emulator
npx expo run:android
# Scan QR code with Expo Go on a physical device
npx expo start
Building with EAS
# Preview build (internal distribution)
eas build --platform all --profile preview
# Production build
eas build --platform all --profile production
# Submit to stores
eas submit --platform ios
eas submit --platform android
Metro Configuration
Metro is configured in metro.config.js to resolve monorepo packages:
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const config = getDefaultConfig(__dirname);
// Watch the monorepo packages
config.watchFolders = [
path.resolve(__dirname, '../../packages/ui'),
path.resolve(__dirname, '../../packages/sdk'),
path.resolve(__dirname, '../../packages/types'),
];
module.exports = config;
Adding a New Screen
-
Create the route file under
app/:app/(tabs)/new-tab.tsx # For a new tabapp/feature/[id].tsx # For a dynamic route -
Add the tab to
(tabs)/_layout.tsxif it's a tab screen -
Use Zustand for local state,
@gospelib/sdkfor API calls -
Use
@gospelib/uicomponents for consistent cross-platform UI -
Consider offline support — cache content in SQLite where appropriate
Troubleshooting
Metro bundler cache issues
npx expo start --clear
Monorepo package not found
Ensure the package is listed in metro.config.js watchFolders and in package.json dependencies with workspace:* protocol.
React version mismatch
The mobile app uses React 18.3 (Expo 52 constraint), while web apps use React 19. The @gospelib/ui package must be compatible with both versions.