Skip to main content

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/sdk for 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} />;
}
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

  1. On first launch, download the core content set (passages, lexicon basics)
  2. Sync additional content in the background when connected
  3. Read from local SQLite first; fall back to API if not cached
  4. 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

  1. Create the route file under app/:

    app/(tabs)/new-tab.tsx # For a new tab
    app/feature/[id].tsx # For a dynamic route
  2. Add the tab to (tabs)/_layout.tsx if it's a tab screen

  3. Use Zustand for local state, @gospelib/sdk for API calls

  4. Use @gospelib/ui components for consistent cross-platform UI

  5. 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.