Lessons from building CFREE, a real estate exam prep app, with React Native, Expo, in-app purchases, push notifications, and Firebase Auth — and releasing it to the App Store and Google Play.
CFREE was a solo/small-team product: practice questions for real estate professionals, shipped to both stores. Expo removed native toolchain tax so we could spend time on study flows instead of Xcode rituals.
Expo made sense because we needed:
Over 100 users and usable ratings were enough to prove the stack. The constraint was time, not theoretical purity.
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
const Stack = createNativeStackNavigator();
export function RootNavigator() {
const { user } = useAuth();
return (
<NavigationContainer>
<Stack.Navigator>
{user ? (
<>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Quiz" component={QuizScreen} />
<Stack.Screen name="Paywall" component={PaywallScreen} />
</>
) : (
<Stack.Screen name="SignIn" component={SignInScreen} />
)}
</Stack.Navigator>
</NavigationContainer>
);
}
Keep paywall as a real screen, not a modal you cannot deep-link. Reviewers and returning users both hit it.
Firebase Authentication covered email and social login. Session persistence used Firebase's own storage; quiz progress used AsyncStorage so a student could lose network mid-set and keep answers.
import AsyncStorage from '@react-native-async-storage/async-storage';
const PROGRESS_KEY = 'quiz-progress-v1';
export async function saveProgress(progress: QuizProgress) {
await AsyncStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
}
export async function loadProgress(): Promise<QuizProgress | null> {
const raw = await AsyncStorage.getItem(PROGRESS_KEY);
return raw ? JSON.parse(raw) : null;
}
Version the key. The first schema will be wrong.
Do not store purchase entitlements only on device. Restore purchases against the store + your backend, then cache the result.
Subscriptions are where mobile apps go to die in QA.
purchase updated, restored, and expired as first-class statestype Entitlement = {
active: boolean;
expiresAt: string | null;
productId: string | null;
};
The UI should have three honest states: free, subscribed, and "purchase pending." Silent failures after Apple's sheet closes are the number-one 1-star review.
Study apps live on reminders.
import * as Notifications from 'expo-notifications';
export async function registerForPush() {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') return null;
const token = (await Notifications.getExpoPushTokenAsync()).data;
await api.savePushToken(token);
return token;
}
Ask for permission after the user finishes a quiz, not on first launch. Schedule local reminders for "daily 10 questions" even if remote push is delayed.
We used UI Kitten 5 with a custom theme: one design language, platform-native navigation chrome.
Pixel-perfect between iOS and Android is a trap. Consistent hierarchy is the goal.
React Native did not make the app "write once." It made the domain logic write once. Native still owns payments, permissions, and review. Plan for that and Expo stays a multiplier instead of a wall.