Mobile App
Doc Status: Good | ✓ Clear summary | ✓ Easy to read | ✓ Matches code | ✓ Good structure | ✓ Professional look | ✓ Visual components
Stack
| Category | Choice | Notes |
|---|
| Framework | Expo + Expo Router | Faster dev, better tooling |
| Styling | NativeWind (Tailwind for RN) | Same class syntax as web |
| Auth | ConvexAuthProvider + expo-secure-store | Tokens in Keychain / EncryptedSharedPreferences |
| Tabs | NativeTabs from expo-router/unstable-native-tabs | Native iOS/Android tab bar |
| Images | Image from expo-image | Better memory management than RN Image |
SafeAreaView
Every screen MUST be wrapped in <SafeAreaView> from react-native-safe-area-context.
import { SafeAreaView } from 'react-native-safe-area-context';
export default function ProfileScreen() {
return (
<SafeAreaView className="flex-1 bg-[#0A1A33]" edges={['top']}>
<View className="flex-1 px-4">
{/* content */}
</View>
</SafeAreaView>
);
}
edges={['top']} — for tab screens (bottom handled by tabs)
edges={['top', 'bottom']} — for screens needing both
Colors — Hex Only
All color values MUST use hex format (#RRGGBB or #RGB). No named colors, no rgb(), no CSS variables.
// ✅ GOOD — hex
<Text className="text-[#4A6AB3]">Hello</Text>
<View className="bg-[#0A1A33]" />
// ❌ BAD — named color
<Text style={{ color: 'white' }}>Hello</Text>
For semi-transparent colors, use Tailwind opacity suffix:
<View className="bg-[#0A1A33]/50" />
Centralize colors in src/constants/app-theme.ts:
export const Colors = {
primary: '#4A6AB3',
background: '#0A1A33',
textSecondary: '#737A8C',
};
NativeTabs
Use NativeTabs from expo-router/unstable-native-tabs — not @react-navigation/bottom-tabs.
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
return (
<NativeTabs
backgroundColor={Colors.background}
blurEffect="systemMaterialDark"
iconColor={{
default: Colors.tabIconDefault,
selected: Colors.tabIconSelected,
}}
>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Icon sf="house.fill" />
<NativeTabs.Trigger.Label hidden />
</NativeTabs.Trigger>
</NativeTabs>
);
}
SF Symbols on iOS via the sf prop. Always hide labels for icon-only tabs: <NativeTabs.Trigger.Label hidden />.
Auth
// apps/mobile/src/lib/ConvexClientProvider.tsx
import { ConvexAuthProvider } from '@convex-dev/auth/react';
import { ConvexReactClient } from 'convex/react';
import * as SecureStore from 'expo-secure-store';
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
const secureStorage = {
getItem: SecureStore.getItemAsync,
setItem: SecureStore.setItemAsync,
removeItem: SecureStore.deleteItemAsync,
};
export function ConvexClientProvider({ children }: { children: React.ReactNode }) {
return (
<ConvexAuthProvider client={convex} storage={secureStorage}>
{children}
</ConvexAuthProvider>
);
}
Tokens are stored in expo-secure-store (Keychain on iOS, EncryptedSharedPreferences on Android) — never in plain AsyncStorage.
Image Handling
Use Image from expo-image (not React Native’s built-in Image):
import { Image } from 'expo-image';
// ✅ GOOD
<Image
source={{ uri: item.imageUrl }}
style={styles.image}
contentFit="cover"
transition={200}
/>
// ❌ BAD — React Native Image
<Image source={{ uri: item.imageUrl }} style={styles.image} />
expo-router Conventions
Layout Files
// app/_layout.tsx — Root layout
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<ConvexClientProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(auth)" />
</Stack>
</ConvexClientProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
// app/(tabs)/_layout.tsx — Tab layout (NativeTabs)
export default function TabLayout() {
return <NativeTabs>{/* triggers */}</NativeTabs>;
}
Screen Files
Each screen is a default export. Use useRouter from expo-router:
import { useRouter } from 'expo-router';
export default function HomeScreen() {
const router = useRouter();
return <TouchableOpacity onPress={() => router.push('/profile')} />;
}