Building a Cross-Platform Mobile App with Expo SDK 53 and React Native
Mobile app development has come a long way from writing separate codebases for iOS and Android. At Storyie, we chose Expo SDK 53 and React Native 0.79 to build a production-ready mobile app that shares code with our Next.js web application while delivering a native mobile experience. In this post, we'll show you how we built a cross-platform mobile app with modern tooling, Firebase integration, and seamless deployment.
Why We Chose Expo for Mobile Development
The Cross-Platform Advantage
Building separate native apps for iOS and Android doubles development time and maintenance overhead. Expo and React Native solve this by enabling a single JavaScript/TypeScript codebase that compiles to native iOS and Android apps.
For Storyie, the cross-platform benefits extend beyond mobile:
- Shared business logic: Our monorepo structure (see our monorepo architecture post) allows us to share database schemas, TypeScript types, and utility functions between web and mobile
- Consistent user experience: Users get the same diary editing features on web and mobile
- Faster iteration: Changes to shared code automatically benefit both platforms
Expo SDK 53 Features
Expo provides built-in modules for common mobile features that would otherwise require complex native code:
- Expo Router v5 for type-safe file-based routing (similar to Next.js App Router)
- Over-the-air (OTA) updates for deploying bug fixes without app store approval
- Development builds for testing custom native modules before production
- EAS Build for cloud-based iOS and Android compilation
- Built-in modules: Camera, file system, notifications, secure storage, and more
React Native 0.79 Improvements
React Native 0.79 brings significant performance and developer experience improvements:
- New Architecture with improved rendering performance
- Better debugging tools with improved error messages and stack traces
- Hermes engine as the default JavaScript runtime for faster startup times
- Improved TypeScript support with better type inference
Project Structure with Expo Router v5
Expo Router v5 brings Next.js-style file-based routing to React Native. Here's our app directory structure:
apps/expo/app/
├── (auth)/ # Auth route group
│ └── login.tsx
├── (tabs)/ # Tab navigation
│ ├── index.tsx # Home tab
│ ├── explore.tsx # Explore tab
│ └── profile.tsx # Profile tab
├── diary/[id].tsx # Dynamic diary detail
├── _layout.tsx # Root layout
└── +not-found.tsx # 404 screenFile-Based Routing
Expo Router uses directories and filenames to define routes. Parentheses create route groups that share layouts without adding path segments:
(auth)/login.tsxrenders at/login(not/auth/login)(tabs)/index.tsxrenders at/with tab navigationdiary/[id].tsxcreates a dynamic route at/diary/:id
Dynamic routes use square brackets. Here's how we handle diary details:
// apps/expo/app/diary/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { useDiaryDetail } from '@/hooks/useDiaryDetail';
export default function DiaryDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { diary, loading } = useDiaryDetail(id);
if (loading) return <LoadingSpinner />;
return (
<View>
<LexicalEditorExpo content={diary.content} />
</View>
);
}Root Layout Configuration
The root layout sets up navigation, providers, and global configuration:
// apps/expo/app/_layout.tsx
import { Stack } from 'expo-router';
import { AuthProvider } from '@/context/Authentication';
import { DefaultVisibilityProvider } from '@/context/DefaultVisibilityContext';
import { FontSizeProvider } from '@/context/FontSizeContext';
export default function RootLayout() {
return (
<AuthProvider>
<DefaultVisibilityProvider>
<FontSizeProvider>
<Stack>
<Stack.Screen name='(tabs)' options={{ headerShown: false }} />
<Stack.Screen name='diary/[id]' options={{ title: 'Diary' }} />
</Stack>
</FontSizeProvider>
</DefaultVisibilityProvider>
</AuthProvider>
);
}Providers wrap the navigation stack, making authentication, theme, and app state available throughout the app. The Stack component from Expo Router handles navigation transitions automatically.
Firebase Integration for Production Apps
Firebase provides essential production app features: crash reporting, analytics, and authentication. We integrated Firebase using React Native Firebase, the official Firebase SDK for React Native.
Firebase Crashlytics for Error Tracking
Crashlytics automatically captures crashes and non-fatal errors, helping us fix issues before users report them:
// apps/expo/services/firebase.ts
import crashlytics from '@react-native-firebase/crashlytics';
export async function logCrashlytics(error: Error) {
await crashlytics().recordError(error);
}
export async function setUserIdentifier(userId: string) {
await crashlytics().setUserId(userId);
}We integrated Crashlytics with our error boundary to catch React errors:
// apps/expo/components/ErrorBoundary.tsx
import crashlytics from '@react-native-firebase/crashlytics';
class ErrorBoundary extends React.Component {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
crashlytics().recordError(error);
// Log component stack for debugging
crashlytics().log(`Component stack: ${errorInfo.componentStack}`);
}
render() {
if (this.state.hasError) {
return <ErrorScreen />;
}
return this.props.children;
}
}Firebase Analytics for User Insights
Analytics helps us understand how users interact with the app:
// apps/expo/services/firebase.ts
import analytics from '@react-native-firebase/analytics';
export async function logAnalyticsEvent(eventName: string, params: object) {
await analytics().logEvent(eventName, params);
}
export async function logScreenView(screenName: string) {
await analytics().logScreenView({
screen_name: screenName,
screen_class: screenName,
});
}We log custom events for key user actions:
// Example: Track diary creation
await logAnalyticsEvent('diary_created', {
visibility: 'private',
content_length: content.length,
has_images: content.includes('image'),
});Firebase Configuration in Expo
Expo requires native Firebase configuration files. We use Expo config plugins to handle this:
// apps/expo/app.json
{
"expo": {
"name": "Storyie",
"slug": "storyie",
"version": "1.0.0",
"plugins": [
"@react-native-firebase/app",
"@react-native-firebase/crashlytics",
[
"@react-native-firebase/analytics",
{
"ios": {
"GoogleService-Info.plist": "./firebase/ios/GoogleService-Info.plist"
},
"android": {
"google-services.json": "./firebase/android/google-services.json"
}
}
],
"expo-router"
]
}
}The config plugin automatically adds Firebase native dependencies during the build process.
Building and Deploying with EAS
EAS (Expo Application Services) simplifies building and deploying to the App Store and Google Play.
EAS Build Configuration
Our eas.json defines three build profiles:
// apps/expo/eas.json
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": true
}
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"ios": {
"buildNumber": "1.0.0"
},
"android": {
"versionCode": 1
}
}
},
"submit": {
"production": {
"ios": {
"appleId": "engineering@storyie.app",
"ascAppId": "your-app-store-connect-id"
}
}
}
}Development builds include developer tools and allow testing custom native modules on physical devices:
# Build for iOS simulator
eas build --profile development --platform ios
# Build for Android device
eas build --profile development --platform androidPreview builds create internal testing versions:
# Create preview build for TestFlight
eas build --profile preview --platform iosProduction builds generate App Store and Play Store releases:
# Build for production
eas build --profile production --platform all
# Submit to app stores
eas submit --platform ios
eas submit --platform androidDevelopment Builds vs Expo Go
Expo Go is great for learning Expo, but production apps need development builds because:
- Custom native modules: Firebase, custom fonts, and native animations require native code
- Custom app icons and splash screens: Branding requires custom assets
- Production-like testing: Test the exact code that will ship to users
- Performance testing: Measure real performance without Expo Go overhead
Over-the-Air (OTA) Updates
EAS Update enables deploying JavaScript changes without app store approval:
# Deploy update to production
eas update --branch production --message "Fix diary save bug"OTA updates work for:
- JavaScript/TypeScript code changes
- UI component updates
- Bug fixes
- Content updates
They don't work for:
- Native module changes
- App icon or splash screen updates
- Permissions changes
For native changes, submit a new build to app stores.
Native UI Components and Platform-Specific Code
React Native provides primitive components that render native iOS and Android views.
React Native Primitives
Instead of HTML elements (<div>, <span>), React Native uses:
import { View, Text, Pressable, ScrollView } from 'react-native';Here's our floating action button component:
// apps/expo/components/diary/DiaryFloatingActions.tsx
import { Pressable, View, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export function DiaryFloatingActions({ onShare, onToggleVisibility }) {
return (
<View style={styles.container}>
<View style={styles.buttonGroup}>
<Pressable onPress={onShare} style={styles.button}>
<Ionicons name='share-outline' size={24} color='#fff' />
</Pressable>
<Pressable onPress={onToggleVisibility} style={styles.button}>
<Ionicons
name={
visibility === 'private'
? 'lock-closed-outline'
: 'lock-open-outline'
}
size={24}
color='#fff'
/>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 24,
right: 24,
},
buttonGroup: {
flexDirection: 'row',
gap: 12,
},
button: {
backgroundColor: '#007AFF',
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
},
});Ionicons for Consistent Icons
Expo includes Ionicons, a comprehensive icon library:
import { Ionicons } from '@expo/vector-icons';
<Ionicons name='share-outline' size={24} color='#007AFF' />;Ionicons automatically uses iOS-style icons on iOS and Material Design icons on Android.
Platform-Specific Styling
Use Platform.OS to apply platform-specific styles:
import { Platform, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
header: {
paddingTop: Platform.OS === 'ios' ? 44 : 24, // iOS safe area
elevation: Platform.OS === 'android' ? 4 : 0, // Android shadow
shadowOpacity: Platform.OS === 'ios' ? 0.2 : 0, // iOS shadow
},
});Or create completely separate components:
import { Platform } from 'react-native';
const Button = Platform.select({
ios: () => require('./ButtonIOS').default,
android: () => require('./ButtonAndroid').default,
})();Local Data Persistence with AsyncStorage
AsyncStorage provides device-local key-value storage for user preferences.
Device-Local Preferences
We store theme mode and default diary visibility locally:
// apps/expo/services/defaultVisibilityService.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = '@default_diary_visibility';
export async function getDefaultVisibility(): Promise<'public' | 'private'> {
try {
const value = await AsyncStorage.getItem(STORAGE_KEY);
return value === 'public' ? 'public' : 'private';
} catch (error) {
console.error('Failed to get default visibility:', error);
return 'private'; // Default to private for privacy
}
}
export async function setDefaultVisibility(visibility: 'public' | 'private') {
try {
await AsyncStorage.setItem(STORAGE_KEY, visibility);
} catch (error) {
console.error('Failed to set default visibility:', error);
}
}AsyncStorage is:
- Asynchronous: All operations return Promises
- Persistent: Data survives app restarts
- Device-local: Not synced across devices
- Unencrypted: Use Expo SecureStore for sensitive data
Context API for State Management
We wrap AsyncStorage with React Context for app-wide state:
// apps/expo/context/DefaultVisibilityContext.tsx
import { createContext, useContext, useState, useEffect } from 'react';
import {
getDefaultVisibility,
setDefaultVisibility,
} from '@/services/defaultVisibilityService';
const DefaultVisibilityContext = createContext<{
defaultVisibility: 'public' | 'private';
setDefaultVisibility: (visibility: 'public' | 'private') => Promise<void>;
}>(null!);
export function DefaultVisibilityProvider({ children }) {
const [defaultVisibility, setVisibility] = useState<'public' | 'private'>(
'private'
);
useEffect(() => {
getDefaultVisibility().then(setVisibility);
}, []);
const updateVisibility = async (visibility: 'public' | 'private') => {
await setDefaultVisibility(visibility);
setVisibility(visibility);
};
return (
<DefaultVisibilityContext.Provider
value={{ defaultVisibility, setDefaultVisibility: updateVisibility }}
>
{children}
</DefaultVisibilityContext.Provider>
);
}
export const useDefaultVisibility = () => useContext(DefaultVisibilityContext);Now any component can access the default visibility preference:
const { defaultVisibility } = useDefaultVisibility();
// Use when creating new diary
const newDiary = {
...diaryData,
visibility: defaultVisibility,
};Cross-Platform Lexical Editor Integration
Storyie uses Lexical for rich text editing on both web and mobile. Our monorepo architecture enables sharing the editor core across platforms. For details on our three-package Lexical architecture, see our cross-platform Lexical editor post.
WebView-Based Rich Text Editor
React Native doesn't support contentEditable or rich text editing natively. We use React Native WebView to run the same Lexical editor as our web app:
// packages/lexical-editor-expo/src/LexicalEditorExpo.tsx
import { WebView } from 'react-native-webview';
export function LexicalEditorExpo({
initialContent,
onContentChange,
}: LexicalEditorExpoProps) {
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="lexical.min.js"></script>
</head>
<body>
<div id="editor"></div>
<script>
// Initialize Lexical editor
const editor = createEditor({
namespace: "storyie-mobile",
nodes: [/* shared nodes from lexical-common */],
onError: console.error,
});
// Load initial content
const initialState = ${JSON.stringify(initialContent)};
editor.setEditorState(editor.parseEditorState(initialState));
// Send updates to React Native
editor.registerUpdateListener(({ editorState }) => {
window.ReactNativeWebView.postMessage(
JSON.stringify(editorState.toJSON())
);
});
</script>
</body>
</html>
`;
return (
<WebView
source={{ html: htmlContent }}
onMessage={(event) => {
const editorState = JSON.parse(event.nativeEvent.data);
onContentChange(editorState);
}}
style={{ flex: 1 }}
/>
);
}Communication Between Native and WebView
WebView communication uses postMessage in both directions:
WebView → React Native (editor updates):
// Inside WebView
window.ReactNativeWebView.postMessage(JSON.stringify(editorState));// React Native component
<WebView
onMessage={(event) => {
const editorState = JSON.parse(event.nativeEvent.data);
onContentChange(editorState);
}}
/>React Native → WebView (commands):
// React Native component
webViewRef.current?.injectJavaScript(`
editor.update(() => {
// Update editor state
});
`);This bidirectional communication enables:
- Real-time editor state updates
- Toolbar commands from native UI
- Custom keyboard shortcuts
- Image insertion from native image picker
Performance Considerations
Mobile apps require careful performance optimization for smooth 60fps experiences.
Optimizing List Rendering
Use FlatList for long lists of diaries:
import { FlatList } from 'react-native';
<FlatList
data={diaries}
renderItem={({ item }) => <DiaryCard diary={item} />}
keyExtractor={(item) => item.id}
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={5}
/>;FlatList only renders visible items plus a small buffer, significantly improving performance for large lists.
Image Optimization
Optimize images for mobile network conditions:
import { Image } from 'expo-image';
<Image
source={{ uri: diary.coverImage }}
placeholder={require('./placeholder.png')}
contentFit='cover'
transition={200}
cachePolicy='memory-disk'
/>;expo-image provides:
- Automatic caching
- Progressive loading
- Blurhash placeholders
- Format conversion (WebP support)
Reducing Bundle Size
Monitor and reduce JavaScript bundle size:
# Analyze bundle size
npx react-native-bundle-visualizer
# Remove unused dependencies
npm install -g depcheck
depcheckHermes engine (enabled by default in React Native 0.79) reduces bundle size by:
- Precompiling JavaScript to bytecode
- Reducing memory usage
- Faster app startup
Conclusion and Key Takeaways
Building Storyie's mobile app with Expo SDK 53 enabled rapid development while maintaining native performance and user experience. Here are the key takeaways:
- Expo SDK 53 provides production-ready mobile development with built-in modules, OTA updates, and cloud builds
- Expo Router v5 brings Next.js-style file-based routing to React Native with type safety
- Firebase integration provides essential production features: crash reporting, analytics, and authentication
- EAS Build simplifies deploying to App Store and Play Store with automated builds and submissions
- Cross-platform code sharing (via our monorepo) reduces development time and ensures consistency between web and mobile
- WebView integration enables using the same Lexical rich text editor on web and mobile
- AsyncStorage and Context API provide device-local state management for user preferences
If you're building a cross-platform mobile app, Expo SDK 53 offers the fastest path from idea to production. The development experience rivals web development, while the native performance meets user expectations.
Related Posts
- Building a Monorepo with pnpm and TypeScript - Learn how we share code between web and mobile
- Cross-Platform Lexical Editor Architecture - Deep dive into our WebView-based rich text editor
Try Storyie
Download Storyie on iOS (App Store) and Android to experience the app firsthand. We're continuously improving the mobile experience based on user feedback.