Appendix C: Essential Feature Implementations

Ready-to-use code for common app features. For boilerplate-specific features — design system, API routes, fonts, icons, app store listing, local dev, and EAS deployment — see the dedicated docs pages in the Boilerplate Docs section of the sidebar.

New in 2026: VidNotes Design System, 6 Premium Font Families, Expo API Routes, AI System Prompts, Icon & Splash Generation, App Store Listing Templates, and Local Dev Server. See the Boilerplate Docs section for full documentation.
Hard Paywall (Default): The boilerplate ships with a 3-layer hard paywall as the default monetization pattern. Users must subscribe before accessing any app features. This architecture is documented in detail on the Architecture page. The paywall guard runs in index.tsx, (app)/_layout.tsx, and the usePremiumStatus hook — all working together via a shared usePremiumStore (Zustand).

Dark Mode

Theme Context Setup

contexts/ThemeContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useColorScheme } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

type ThemeMode = 'light' | 'dark' | 'system';

interface ThemeContextType {
  theme: 'light' | 'dark';
  themeMode: ThemeMode;
  setThemeMode: (mode: ThemeMode) => void;
  colors: typeof lightColors;
}

const lightColors = {
  primary: '#7C3AED',
  background: '#FFFFFF',
  surface: '#F9FAFB',
  surfaceSecondary: '#F3F4F6',
  text: '#111827',
  textSecondary: '#6B7280',
  textTertiary: '#9CA3AF',
  border: '#E5E7EB',
  error: '#EF4444',
  success: '#22C55E',
  warning: '#F59E0B',
};

const darkColors = {
  primary: '#A78BFA',
  background: '#111827',
  surface: '#1F2937',
  surfaceSecondary: '#374151',
  text: '#F9FAFB',
  textSecondary: '#D1D5DB',
  textTertiary: '#9CA3AF',
  border: '#374151',
  error: '#F87171',
  success: '#4ADE80',
  warning: '#FBBF24',
};

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const systemColorScheme = useColorScheme();
  const [themeMode, setThemeModeState] = useState<ThemeMode>('system');

  useEffect(() => {
    loadThemePreference();
  }, []);

  const loadThemePreference = async () => {
    const saved = await AsyncStorage.getItem('themeMode');
    if (saved) {
      setThemeModeState(saved as ThemeMode);
    }
  };

  const setThemeMode = async (mode: ThemeMode) => {
    setThemeModeState(mode);
    await AsyncStorage.setItem('themeMode', mode);
  };

  const theme =
    themeMode === 'system'
      ? systemColorScheme || 'light'
      : themeMode;

  const colors = theme === 'dark' ? darkColors : lightColors;

  return (
    <ThemeContext.Provider value={{ theme, themeMode, setThemeMode, colors }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

Theme Toggle Component

components/ThemeToggle.tsx
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useTheme } from '@/contexts/ThemeContext';

const options = [
  { value: 'light', label: 'Light' },
  { value: 'dark', label: 'Dark' },
  { value: 'system', label: 'System' },
] as const;

export function ThemeToggle() {
  const { themeMode, setThemeMode, colors } = useTheme();

  return (
    <View style={styles.container}>
      <Text style={[styles.label, { color: colors.text }]}>Theme</Text>
      <View style={[styles.options, { backgroundColor: colors.surface }]}>
        {options.map((option) => (
          <TouchableOpacity
            key={option.value}
            style={[
              styles.option,
              themeMode === option.value && {
                backgroundColor: colors.primary,
              },
            ]}
            onPress={() => setThemeMode(option.value)}
          >
            <Text
              style={[
                styles.optionText,
                {
                  color:
                    themeMode === option.value ? '#FFFFFF' : colors.textSecondary,
                },
              ]}
            >
              {option.label}
            </Text>
          </TouchableOpacity>
        ))}
      </View>
    </View>
  );
}

Deep Linking

Configure app.json

app.json
{
  "expo": {
    "scheme": "myapp",
    "ios": {
      "bundleIdentifier": "com.yourcompany.myapp",
      "associatedDomains": ["applinks:yourdomain.com"]
    },
    "android": {
      "package": "com.yourcompany.myapp",
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            {
              "scheme": "https",
              "host": "yourdomain.com",
              "pathPrefix": "/app"
            }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

Handle Deep Links

app/_layout.tsx
import { useEffect } from 'react';
import { Linking } from 'react-native';
import { router, useRootNavigationState } from 'expo-router';

export default function RootLayout() {
  const navigationState = useRootNavigationState();

  useEffect(() => {
    if (!navigationState?.key) return;

    // Handle deep links
    const handleDeepLink = (event: { url: string }) => {
      handleUrl(event.url);
    };

    // Get initial URL (app opened from link)
    Linking.getInitialURL().then((url) => {
      if (url) handleUrl(url);
    });

    // Listen for URL events (app already open)
    const subscription = Linking.addEventListener('url', handleDeepLink);

    return () => subscription.remove();
  }, [navigationState?.key]);

  const handleUrl = (url: string) => {
    const { hostname, pathname, searchParams } = new URL(url);

    // Handle different deep link paths
    if (pathname.startsWith('/profile/')) {
      const userId = pathname.split('/')[2];
      router.push(`/profile/${userId}`);
    } else if (pathname.startsWith('/post/')) {
      const postId = pathname.split('/')[2];
      router.push(`/post/${postId}`);
    } else if (pathname === '/premium') {
      router.push('/paywall');
    }
  };

  return <Stack />;
}

Generate Deep Links

utils/deepLinks.ts
import * as Linking from 'expo-linking';

export function createDeepLink(path: string): string {
  return Linking.createURL(path);
}

export function createWebLink(path: string): string {
  return `https://yourdomain.com/app${path}`;
}

// Usage
const profileLink = createDeepLink('/profile/123');
// => "myapp://profile/123"

const shareableLink = createWebLink('/post/456');
// => "https://yourdomain.com/app/post/456"

Share with Deep Links

import { Share } from 'react-native';
import { createWebLink } from '@/utils/deepLinks';

async function sharePost(postId: string, title: string) {
  const url = createWebLink(`/post/${postId}`);

  await Share.share({
    message: `Check out "${title}" on MyApp`,
    url,
  });
}

Push Notifications

Setup

npx expo install expo-notifications expo-device expo-constants

Request Permission & Get Token

lib/notifications.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import Constants from 'expo-constants';
import { convex } from './convex';

// Configure notification behavior
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

export async function registerForPushNotifications(): Promise<string | null> {
  // Only works on physical devices
  if (!Device.isDevice) {
    console.log('Push notifications only work on physical devices');
    return null;
  }

  // Check existing permission
  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  // Request if not already granted
  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

  if (finalStatus !== 'granted') {
    console.log('Push notification permission not granted');
    return null;
  }

  // Get Expo push token
  const projectId = Constants.expoConfig?.extra?.eas?.projectId;
  const token = (
    await Notifications.getExpoPushTokenAsync({ projectId })
  ).data;

  // Android-specific channel setup
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'Default',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: '#7C3AED',
    });
  }

  return token;
}

export async function savePushToken(userId: string, token: string) {
  await convex.from('push_tokens').upsert({
    user_id: userId,
    token,
    platform: Platform.OS,
    updated_at: new Date().toISOString(),
  });
}

Listen for Notifications

hooks/useNotifications.ts
import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { router } from 'expo-router';

export function useNotifications() {
  const notificationListener = useRef<Notifications.Subscription>();
  const responseListener = useRef<Notifications.Subscription>();

  useEffect(() => {
    // Notification received while app is foregrounded
    notificationListener.current =
      Notifications.addNotificationReceivedListener((notification) => {
        console.log('Notification received:', notification);
      });

    // User tapped on notification
    responseListener.current =
      Notifications.addNotificationResponseReceivedListener((response) => {
        const data = response.notification.request.content.data;

        // Handle navigation based on notification data
        if (data.type === 'new_message') {
          router.push(`/chat/${data.chatId}`);
        } else if (data.type === 'generation_complete') {
          router.push(`/generation/${data.generationId}`);
        }
      });

    return () => {
      if (notificationListener.current) {
        Notifications.removeNotificationSubscription(notificationListener.current);
      }
      if (responseListener.current) {
        Notifications.removeNotificationSubscription(responseListener.current);
      }
    };
  }, []);
}

Onboarding Flow

app/onboarding.tsx
import { useState, useRef } from 'react';
import {
  View,
  Text,
  StyleSheet,
  Dimensions,
  FlatList,
  TouchableOpacity,
  Image,
} from 'react-native';
import { router } from 'expo-router';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Button } from '@/components/ui/Button';

const { width } = Dimensions.get('window');

const slides = [
  {
    id: '1',
    title: 'Welcome to MyApp',
    description: 'The best way to do amazing things with AI',
    image: require('@/assets/onboarding-1.png'),
  },
  {
    id: '2',
    title: 'Create Anything',
    description: 'Generate images, text, and more in seconds',
    image: require('@/assets/onboarding-2.png'),
  },
  {
    id: '3',
    title: 'Save & Share',
    description: 'Keep your creations and share with friends',
    image: require('@/assets/onboarding-3.png'),
  },
];

export default function OnboardingScreen() {
  const [currentIndex, setCurrentIndex] = useState(0);
  const flatListRef = useRef<FlatList>(null);

  const handleNext = () => {
    if (currentIndex < slides.length - 1) {
      flatListRef.current?.scrollToIndex({ index: currentIndex + 1 });
    } else {
      completeOnboarding();
    }
  };

  const completeOnboarding = async () => {
    await AsyncStorage.setItem('hasSeenOnboarding', 'true');
    router.replace('/(tabs)');
  };

  const renderSlide = ({ item }) => (
    <View style={styles.slide}>
      <Image source={item.image} style={styles.image} resizeMode="contain" />
      <Text style={styles.title}>{item.title}</Text>
      <Text style={styles.description}>{item.description}</Text>
    </View>
  );

  return (
    <View style={styles.container}>
      <FlatList
        ref={flatListRef}
        data={slides}
        renderItem={renderSlide}
        horizontal
        pagingEnabled
        showsHorizontalScrollIndicator={false}
        onMomentumScrollEnd={(e) => {
          const index = Math.round(e.nativeEvent.contentOffset.x / width);
          setCurrentIndex(index);
        }}
      />

      {/* Dots */}
      <View style={styles.dots}>
        {slides.map((_, index) => (
          <View
            key={index}
            style={[
              styles.dot,
              index === currentIndex && styles.activeDot,
            ]}
          />
        ))}
      </View>

      {/* Buttons */}
      <View style={styles.buttons}>
        <Button
          title={currentIndex === slides.length - 1 ? 'Get Started' : 'Next'}
          onPress={handleNext}
        />
        {currentIndex < slides.length - 1 && (
          <TouchableOpacity onPress={completeOnboarding} style={styles.skip}>
            <Text style={styles.skipText}>Skip</Text>
          </TouchableOpacity>
        )}
      </View>
    </View>
  );
}

Haptic Feedback

Setup

npx expo install expo-haptics

Haptic Utility

utils/haptics.ts
import * as Haptics from 'expo-haptics';
import { Platform } from 'react-native';

export const haptics = {
  // Light tap - button press
  light: () => {
    if (Platform.OS !== 'web') {
      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
    }
  },

  // Medium tap - toggle, selection
  medium: () => {
    if (Platform.OS !== 'web') {
      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
    }
  },

  // Heavy tap - important action
  heavy: () => {
    if (Platform.OS !== 'web') {
      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
    }
  },

  // Success - task completed
  success: () => {
    if (Platform.OS !== 'web') {
      Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
    }
  },

  // Warning - attention needed
  warning: () => {
    if (Platform.OS !== 'web') {
      Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
    }
  },

  // Error - something went wrong
  error: () => {
    if (Platform.OS !== 'web') {
      Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
    }
  },

  // Selection change
  selection: () => {
    if (Platform.OS !== 'web') {
      Haptics.selectionAsync();
    }
  },
};

Usage

import { haptics } from '@/utils/haptics';

// Button press
<TouchableOpacity onPress={() => {
  haptics.light();
  handlePress();
}}>

// Success state
const handleSubmit = async () => {
  try {
    await submitForm();
    haptics.success();
    Alert.alert('Success!');
  } catch (error) {
    haptics.error();
    Alert.alert('Error', error.message);
  }
};

Localization (i18n)

Setup

npm install i18next react-i18next

Translation Files

locales/en.ts
export default {
  common: {
    loading: 'Loading...',
    error: 'Something went wrong',
    retry: 'Try again',
    cancel: 'Cancel',
    save: 'Save',
    delete: 'Delete',
    confirm: 'Confirm',
  },
  auth: {
    signIn: 'Sign In',
    signUp: 'Sign Up',
    signOut: 'Sign Out',
    email: 'Email',
    password: 'Password',
    forgotPassword: 'Forgot password?',
  },
  settings: {
    title: 'Settings',
    language: 'Language',
    theme: 'Theme',
    notifications: 'Notifications',
    privacy: 'Privacy Policy',
    terms: 'Terms of Service',
    version: 'Version',
  },
  paywall: {
    title: 'Upgrade to Pro',
    subtitle: 'Unlock all features',
    monthly: 'Monthly',
    yearly: 'Yearly',
    restore: 'Restore Purchases',
    freeTrial: '7-day free trial',
  },
};

i18n Setup

lib/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as Localization from 'expo-localization';
import AsyncStorage from '@react-native-async-storage/async-storage';

import en from '@/locales/en';
import es from '@/locales/es';

const resources = {
  en: { translation: en },
  es: { translation: es },
};

const LANGUAGE_KEY = 'user_language';

export const initI18n = async () => {
  const savedLanguage = await AsyncStorage.getItem(LANGUAGE_KEY);
  const deviceLanguage = Localization.locale.split('-')[0];

  await i18n.use(initReactI18next).init({
    resources,
    lng: savedLanguage || deviceLanguage || 'en',
    fallbackLng: 'en',
    interpolation: {
      escapeValue: false,
    },
  });
};

export const changeLanguage = async (lang: string) => {
  await AsyncStorage.setItem(LANGUAGE_KEY, lang);
  await i18n.changeLanguage(lang);
};

export default i18n;

Usage

import { useTranslation } from 'react-i18next';

function MyComponent() {
  const { t } = useTranslation();

  return (
    <View>
      <Text>{t('common.loading')}</Text>
      <Button title={t('auth.signIn')} onPress={handleSignIn} />
    </View>
  );
}

Form Validation

Setup

npm install react-hook-form zod @hookform/resolvers

Validation Schema

schemas/auth.schema.ts
import { z } from 'zod';

export const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

export const signupSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Must contain uppercase letter')
    .regex(/[a-z]/, 'Must contain lowercase letter')
    .regex(/[0-9]/, 'Must contain number'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'],
});

export type LoginInput = z.infer<typeof loginSchema>;
export type SignupInput = z.infer<typeof signupSchema>;

Form Component

components/LoginForm.tsx
import { View, TextInput, Text, StyleSheet } from 'react-native';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, LoginInput } from '@/schemas/auth.schema';
import { Button } from '@/components/ui/Button';

interface LoginFormProps {
  onSubmit: (data: LoginInput) => Promise<void>;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const {
    control,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<LoginInput>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  });

  return (
    <View style={styles.form}>
      <Controller
        control={control}
        name="email"
        render={({ field: { onChange, onBlur, value } }) => (
          <View style={styles.field}>
            <TextInput
              style={[styles.input, errors.email && styles.inputError]}
              placeholder="Email"
              value={value}
              onChangeText={onChange}
              onBlur={onBlur}
              autoCapitalize="none"
              keyboardType="email-address"
              autoComplete="email"
            />
            {errors.email && (
              <Text style={styles.error}>{errors.email.message}</Text>
            )}
          </View>
        )}
      />

      <Controller
        control={control}
        name="password"
        render={({ field: { onChange, onBlur, value } }) => (
          <View style={styles.field}>
            <TextInput
              style={[styles.input, errors.password && styles.inputError]}
              placeholder="Password"
              value={value}
              onChangeText={onChange}
              onBlur={onBlur}
              secureTextEntry
              autoComplete="password"
            />
            {errors.password && (
              <Text style={styles.error}>{errors.password.message}</Text>
            )}
          </View>
        )}
      />

      <Button
        title="Sign In"
        onPress={handleSubmit(onSubmit)}
        loading={isSubmitting}
      />
    </View>
  );
}

OTA Updates (EAS Update)

Setup

npx expo install expo-updates
eas update:configure

app.json Configuration

{
  "expo": {
    "updates": {
      "url": "https://u.expo.dev/your-project-id"
    },
    "runtimeVersion": {
      "policy": "appVersion"
    }
  }
}

Publish an Update

# Publish to all users
eas update --branch production --message "Bug fixes"

# Publish to preview channel
eas update --branch preview --message "New feature testing"

Check for Updates in App

hooks/useAppUpdate.ts
import { useEffect } from 'react';
import * as Updates from 'expo-updates';
import { Alert } from 'react-native';

export function useAppUpdate() {
  useEffect(() => {
    checkForUpdates();
  }, []);

  const checkForUpdates = async () => {
    if (__DEV__) return; // Skip in development

    try {
      const update = await Updates.checkForUpdateAsync();

      if (update.isAvailable) {
        await Updates.fetchUpdateAsync();

        Alert.alert(
          'Update Available',
          'A new version is ready. Restart to apply?',
          [
            { text: 'Later', style: 'cancel' },
            {
              text: 'Restart',
              onPress: () => Updates.reloadAsync(),
            },
          ]
        );
      }
    } catch (error) {
      console.log('Error checking for updates:', error);
    }
  };
}

App Review Request

Setup

npx expo install expo-store-review

Smart Review Prompt

lib/reviewPrompt.ts
import * as StoreReview from 'expo-store-review';
import AsyncStorage from '@react-native-async-storage/async-storage';

const REVIEW_KEY = 'app_review_status';
const MIN_ACTIONS = 5; // Show after 5 successful actions
const MIN_DAYS = 3; // Wait at least 3 days after install

interface ReviewStatus {
  hasAsked: boolean;
  actionCount: number;
  installDate: string;
}

export async function trackAction() {
  const status = await getReviewStatus();
  status.actionCount++;
  await saveReviewStatus(status);

  // Check if we should ask for review
  if (shouldAskForReview(status)) {
    await requestReview();
  }
}

function shouldAskForReview(status: ReviewStatus): boolean {
  if (status.hasAsked) return false;
  if (status.actionCount < MIN_ACTIONS) return false;

  const daysSinceInstall = getDaysSince(status.installDate);
  if (daysSinceInstall < MIN_DAYS) return false;

  return true;
}

async function requestReview() {
  const isAvailable = await StoreReview.isAvailableAsync();

  if (isAvailable) {
    await StoreReview.requestReview();

    const status = await getReviewStatus();
    status.hasAsked = true;
    await saveReviewStatus(status);
  }
}

Usage

// After successful generation, purchase, or positive action
import { trackAction } from '@/lib/reviewPrompt';

const handleGenerationSuccess = async () => {
  // ... generation logic
  await trackAction(); // May trigger review prompt
};

Splash Screen

Design Requirements

PlatformSizeFormat
iOS2732 x 2732PNG (centered, will be cropped)
Android1284 x 2778PNG
Universal1284 x 2778PNG (recommended)
Canva Template: Search for "App Splash Screen 1284x2778" or create custom dimensions.

Setup

npx expo install expo-splash-screen

app.json Configuration

{
  "expo": {
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#7C3AED"
    },
    "ios": {
      "splash": {
        "image": "./assets/splash.png",
        "resizeMode": "contain",
        "backgroundColor": "#7C3AED",
        "dark": {
          "image": "./assets/splash-dark.png",
          "backgroundColor": "#1F2937"
        }
      }
    }
  }
}

Keep Splash Visible During Loading

app/_layout.tsx
import { useEffect, useState, useCallback } from 'react';
import * as SplashScreen from 'expo-splash-screen';
import * as Font from 'expo-font';
import { View } from 'react-native';

// Prevent auto-hide
SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [appIsReady, setAppIsReady] = useState(false);

  useEffect(() => {
    async function prepare() {
      try {
        // Load fonts, fetch initial data, etc.
        await Font.loadAsync({
          'Inter-Regular': require('@/assets/fonts/Inter-Regular.ttf'),
          'Inter-Bold': require('@/assets/fonts/Inter-Bold.ttf'),
        });

        // Artificial delay for branding (optional)
        await new Promise(resolve => setTimeout(resolve, 1000));
      } catch (e) {
        console.warn(e);
      } finally {
        setAppIsReady(true);
      }
    }

    prepare();
  }, []);

  const onLayoutRootView = useCallback(async () => {
    if (appIsReady) {
      await SplashScreen.hideAsync();
    }
  }, [appIsReady]);

  if (!appIsReady) {
    return null;
  }

  return (
    <View style={{ flex: 1 }} onLayout={onLayoutRootView}>
      <Stack />
    </View>
  );
}

App Initialization

Complete Loading Pattern

app/_layout.tsx
import { useEffect, useState, useCallback } from 'react';
import * as SplashScreen from 'expo-splash-screen';
import * as Font from 'expo-font';
import { Asset } from 'expo-asset';
import { View } from 'react-native';
import { initI18n } from '@/lib/i18n';
import { convex } from '@/lib/convex';
import { useAuthStore } from '@/stores/authStore';

SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [appIsReady, setAppIsReady] = useState(false);
  const setUser = useAuthStore((state) => state.setUser);
  const setSession = useAuthStore((state) => state.setSession);

  useEffect(() => {
    async function prepare() {
      try {
        // 1. Load fonts
        await Font.loadAsync({
          'Inter-Regular': require('@/assets/fonts/Inter-Regular.ttf'),
          'Inter-Medium': require('@/assets/fonts/Inter-Medium.ttf'),
          'Inter-SemiBold': require('@/assets/fonts/Inter-SemiBold.ttf'),
          'Inter-Bold': require('@/assets/fonts/Inter-Bold.ttf'),
        });

        // 2. Preload critical images
        await Asset.loadAsync([
          require('@/assets/images/logo.png'),
          require('@/assets/images/onboarding-1.png'),
          require('@/assets/images/onboarding-2.png'),
          require('@/assets/images/onboarding-3.png'),
        ]);

        // 3. Initialize i18n
        await initI18n();

        // 4. Check existing auth session
        const { data: { session } } = await convex.auth.getSession();
        if (session) {
          setSession(session);
          setUser(session.user);
        }

        // 5. Any other initialization...

      } catch (e) {
        console.warn('App initialization error:', e);
      } finally {
        setAppIsReady(true);
      }
    }

    prepare();
  }, []);

  const onLayoutRootView = useCallback(async () => {
    if (appIsReady) {
      await SplashScreen.hideAsync();
    }
  }, [appIsReady]);

  if (!appIsReady) {
    return null;
  }

  return (
    <View style={{ flex: 1 }} onLayout={onLayoutRootView}>
      <Providers>
        <Stack />
      </Providers>
    </View>
  );
}

Font Loading Hook

hooks/useFonts.ts
import * as Font from 'expo-font';
import { useEffect, useState } from 'react';

const customFonts = {
  'Inter-Regular': require('@/assets/fonts/Inter-Regular.ttf'),
  'Inter-Medium': require('@/assets/fonts/Inter-Medium.ttf'),
  'Inter-SemiBold': require('@/assets/fonts/Inter-SemiBold.ttf'),
  'Inter-Bold': require('@/assets/fonts/Inter-Bold.ttf'),
};

export function useFonts() {
  const [fontsLoaded, setFontsLoaded] = useState(false);

  useEffect(() => {
    async function loadFonts() {
      await Font.loadAsync(customFonts);
      setFontsLoaded(true);
    }
    loadFonts();
  }, []);

  return fontsLoaded;
}

Asset Preloading Utility

utils/preloadAssets.ts
import { Asset } from 'expo-asset';
import * as Font from 'expo-font';

const images = [
  require('@/assets/images/logo.png'),
  require('@/assets/images/placeholder.png'),
];

const fonts = {
  'Inter-Regular': require('@/assets/fonts/Inter-Regular.ttf'),
  'Inter-Bold': require('@/assets/fonts/Inter-Bold.ttf'),
};

export async function preloadAssets(): Promise<void> {
  const imageAssets = images.map((image) => Asset.loadAsync(image));
  const fontAssets = Font.loadAsync(fonts);

  await Promise.all([...imageAssets, fontAssets]);
}

Keyboard Handling

components/KeyboardAvoidingWrapper.tsx
import { ReactNode } from 'react';
import {
  KeyboardAvoidingView,
  Platform,
  StyleSheet,
  TouchableWithoutFeedback,
  Keyboard,
} from 'react-native';

interface Props {
  children: ReactNode;
}

export function KeyboardAvoidingWrapper({ children }: Props) {
  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
      keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}
    >
      <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
        {children}
      </TouchableWithoutFeedback>
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
});

Safe Area Handling

Setup

npx expo install react-native-safe-area-context

Provider Setup

app/_layout.tsx
import { SafeAreaProvider } from 'react-native-safe-area-context';

export default function RootLayout() {
  return (
    <SafeAreaProvider>
      <Stack />
    </SafeAreaProvider>
  );
}

Using SafeAreaView

import { SafeAreaView } from 'react-native-safe-area-context';
import { View, Text, StyleSheet } from 'react-native';

export function HomeScreen() {
  return (
    <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
      <View style={styles.content}>
        <Text>Content safe from notches and home indicators</Text>
      </View>
    </SafeAreaView>
  );
}

Using useSafeAreaInsets Hook

import { View, Text, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

export function CustomHeader({ title }: { title: string }) {
  const insets = useSafeAreaInsets();

  return (
    <View style={[styles.header, { paddingTop: insets.top + 12 }]}>
      <Text style={styles.title}>{title}</Text>
    </View>
  );
}

Testing

Setup

npm install --save-dev jest @testing-library/react-native @testing-library/jest-native jest-expo

Testing Components

__tests__/Button.test.tsx
import { render, fireEvent } from '@testing-library/react-native';
import { Button } from '@/components/ui/Button';

describe('Button', () => {
  it('renders correctly', () => {
    const { getByText } = render(<Button title="Press me" onPress={() => {}} />);
    expect(getByText('Press me')).toBeTruthy();
  });

  it('calls onPress when pressed', () => {
    const onPress = jest.fn();
    const { getByText } = render(<Button title="Press me" onPress={onPress} />);

    fireEvent.press(getByText('Press me'));
    expect(onPress).toHaveBeenCalledTimes(1);
  });

  it('shows loading state', () => {
    const { getByTestId } = render(
      <Button title="Press me" onPress={() => {}} loading />
    );
    expect(getByTestId('loading-indicator')).toBeTruthy();
  });
});

Running Tests

# Run all tests
npm test

# Run with coverage
npm test -- --coverage

# Run in watch mode
npm test -- --watch

Image Caching

Setup

npx expo install expo-image

Cached Image Component

components/CachedImage.tsx
import { Image, ImageProps } from 'expo-image';

const blurhash = 'L6PZfSi_.AyE_3t7t7R**0o#DgR4';

interface CachedImageProps extends Omit<ImageProps, 'source'> {
  uri: string;
}

export function CachedImage({ uri, style, ...props }: CachedImageProps) {
  return (
    <Image
      source={{ uri }}
      placeholder={blurhash}
      contentFit="cover"
      transition={200}
      cachePolicy="memory-disk"
      style={style}
      {...props}
    />
  );
}

// Usage
<CachedImage
  uri="https://example.com/image.jpg"
  style={{ width: 200, height: 200, borderRadius: 12 }}
/>

Offline Detection

hooks/useOffline.ts
import { useState, useEffect } from 'react';
import NetInfo from '@react-native-community/netinfo';

export function useOffline() {
  const [isOffline, setIsOffline] = useState(false);

  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener((state) => {
      setIsOffline(!state.isConnected);
    });

    return () => unsubscribe();
  }, []);

  return isOffline;
}
components/OfflineBanner.tsx
import { View, Text, StyleSheet } from 'react-native';
import { useOffline } from '@/hooks/useOffline';

export function OfflineBanner() {
  const isOffline = useOffline();

  if (!isOffline) return null;

  return (
    <View style={styles.banner}>
      <Text style={styles.text}>No internet connection</Text>
    </View>
  );
}

Official Documentation

For the latest APIs and best practices, refer to the official documentation:

Features Built-In

Dark mode, push, deep links included

Essential features already implemented and tested on both platforms.

Dark mode toggle
Push notifications
Deep linking ready
Get ShipReactNative
Save 40+ hours of setup