The screens are the easy part of a migration. The expensive surprises live in the modules: every Expo package hid a native API behind one JavaScript call, and on the other side of the migration you own both of those APIs directly. This is the map for the common ones.
Start with an inventory
Before porting a single screen, list every Expo and community package in package.json that touches native code, and for each one the screens that use it. The migration order should follow this list: a screen is only portable once everything it depends on has a native equivalent on both platforms.
Also note behaviour the JavaScript layer quietly smoothed over — permission prompts, default timeouts, retry logic. Those are the parity mismatches you will otherwise find in review.
# A rough first pass
grep -oE '"(expo-[a-z-]+|react-native-[a-z-]+)"' package.json | sort -uThe map, at a glance
Difficulty is days of work per package, covering both platforms: low is about half a day, medium about two days, high about five. A package not in a map like this one should be budgeted at three days until somebody reads it, because the unknown ones are where estimates go wrong.
Read the status column before you plan against any row. Every mapping below is proposed from the platform documentation, not proven by a shipped migration. That is an honest limit rather than a hedge: the published Bluesky port is three screens that needed no module replaced, so its checkpoint plan has no module step at all. The first migration that does replace a module will be published the same way as the parity numbers were, and these rows will say so.
| Package | Days | iOS | Android | What bites | Status |
|---|---|---|---|---|---|
| expo-haptics | 0.5 | UIImpactFeedbackGenerator, or .sensoryFeedback | LocalHapticFeedback, or View.performHapticFeedback | Nothing. Straight replacement. | Proposed |
| expo-image-picker | 0.5 | PhotosPicker, or PHPickerViewController | Photo Picker via PickVisualMedia | Nothing much, and the system pickers need no photo permission at all, which is a privacy improvement worth keeping. | Proposed |
| expo-file-system | 0.5 | FileManager, URLSession for downloads | filesDir and cacheDir, WorkManager or DownloadManager | Stored paths. Expo's document directory locations must be preserved or existing files are orphaned. | Proposed |
| react-native-mmkv | 0.5 | UserDefaults or SwiftData, or the MMKV iOS SDK | Jetpack DataStore, or the MMKV Android SDK | Keep the native MMKV SDK for the first native release if users have persisted state, or migrate it on first launch. | Proposed |
| react-native-purchases-ui | 0.5 | RevenueCatUI PaywallView | RevenueCat Paywalls for Compose | Nothing. Remote paywalls already render natively. | Proposed |
| expo-camera | 2 | AVCaptureSession, plus DataScannerViewController for codes | CameraX, plus ML Kit Barcode Scanning | Preview, capture and code scanning are three separate pieces of native wiring on each platform. | Proposed |
| expo-secure-store | 2 | Keychain Services | Android Keystore with DataStore | Existing items must stay readable: same keychain service and access group, same key alias. Get it wrong and every user is signed out by the update. | Proposed |
| @react-native-async-storage/async-storage | 2 | UserDefaults, or a file in Application Support | DataStore Preferences | Existing data is a manifest file on iOS and SQLite on Android. It has to be migrated on first launch. | Proposed |
| expo-location | 2 | CLLocationManager, CLMonitor for geofences | FusedLocationProviderClient, GeofencingClient | Background location pushes this to high: Always authorisation on iOS, a foreground service on Android. | Proposed |
| expo-auth-session | 2 | ASWebAuthenticationSession with Universal Links | Custom Tabs or AppAuth with verified App Links | Redirect URIs registered with your identity provider change, so the provider configuration changes with the app. | Proposed |
| react-native-purchases | 2 | StoreKit 2, or the RevenueCat iOS SDK | Play Billing, or the RevenueCat Android SDK | Keeping RevenueCat carries customers and entitlements across. Dropping it makes this high. | Proposed |
| expo-notifications | 5 | UNUserNotificationCenter with APNs | FirebaseMessagingService with NotificationCompat | Expo push tokens become raw APNs and FCM tokens, so your backend send path changes too. Cold-start tap routing is the part that gets missed. | Proposed |
| expo-updates | 5 | No equivalent. Remote config, feature flags, phased releases. | No equivalent. Remote config, feature flags, staged rollouts, Play In-App Updates. | Hot-fixing by OTA disappears. What you replace is the release process, not the code. | Proposed |
Camera and photos
expo-camera becomes AVFoundation on iOS (an AVCaptureSession wrapped for SwiftUI) and CameraX on Android. Both give you more control than the Expo wrapper did, and both need their permission strings declared natively: NSCameraUsageDescription in Info.plist, and the CAMERA permission in the Android manifest.
expo-image-picker becomes PhotosPicker from PhotosUI on iOS and the photo picker via ActivityResultContracts.PickVisualMedia on Android. Neither needs full photo-library permission for simple picking, which is a privacy improvement worth keeping.
// iOS
import PhotosUI
PhotosPicker(selection: $item, matching: .images) { Text("Choose photo") }
// Android
val pick = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri -> /* ... */ }
pick.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))Notifications
expo-notifications becomes UNUserNotificationCenter on iOS, with APNs for remote delivery, and NotificationManagerCompat plus Firebase Cloud Messaging on Android. If you used Expo's push service, your server now talks to APNs and FCM directly, or to a provider that does.
Check two behaviours explicitly: what happens when a notification arrives while the app is in the foreground, and how a tap routes into the right screen. Both were configured for you before and both are easy to get subtly different between platforms.
Secure storage and files
expo-secure-store becomes Keychain Services on iOS and the Android Keystore on Android, typically holding a key that encrypts values you persist with DataStore. Plan a one-time migration of existing values: tokens stored by the React Native app need to be readable by the native one, or every user is signed out on update.
expo-file-system becomes FileManager on iOS and the app's filesDir and cacheDir on Android. Keep the same directory layout if you can, so files written by the old app are still found by the new one.
Purchases and haptics
react-native-purchases (RevenueCat) maps directly to RevenueCat's native iOS and Android SDKs, which keeps entitlements and receipts continuous across the migration. Moving to StoreKit 2 and Play Billing directly is possible but is a separate project — do not combine it with the migration.
expo-haptics becomes UIImpactFeedbackGenerator and UINotificationFeedbackGenerator on iOS, and LocalHapticFeedback in Compose or the View's performHapticFeedback on Android. Match intensity by feel on a real device; simulators tell you nothing here.