Nativestarter
Guides

Mapping Expo modules to native iOS and Android

Migrating, 8 min read

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 -u

The 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.

PackageDaysiOSAndroidWhat bitesStatus
expo-haptics0.5UIImpactFeedbackGenerator, or .sensoryFeedbackLocalHapticFeedback, or View.performHapticFeedbackNothing. Straight replacement.Proposed
expo-image-picker0.5PhotosPicker, or PHPickerViewControllerPhoto Picker via PickVisualMediaNothing much, and the system pickers need no photo permission at all, which is a privacy improvement worth keeping.Proposed
expo-file-system0.5FileManager, URLSession for downloadsfilesDir and cacheDir, WorkManager or DownloadManagerStored paths. Expo's document directory locations must be preserved or existing files are orphaned.Proposed
react-native-mmkv0.5UserDefaults or SwiftData, or the MMKV iOS SDKJetpack DataStore, or the MMKV Android SDKKeep the native MMKV SDK for the first native release if users have persisted state, or migrate it on first launch.Proposed
react-native-purchases-ui0.5RevenueCatUI PaywallViewRevenueCat Paywalls for ComposeNothing. Remote paywalls already render natively.Proposed
expo-camera2AVCaptureSession, plus DataScannerViewController for codesCameraX, plus ML Kit Barcode ScanningPreview, capture and code scanning are three separate pieces of native wiring on each platform.Proposed
expo-secure-store2Keychain ServicesAndroid Keystore with DataStoreExisting 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-storage2UserDefaults, or a file in Application SupportDataStore PreferencesExisting data is a manifest file on iOS and SQLite on Android. It has to be migrated on first launch.Proposed
expo-location2CLLocationManager, CLMonitor for geofencesFusedLocationProviderClient, GeofencingClientBackground location pushes this to high: Always authorisation on iOS, a foreground service on Android.Proposed
expo-auth-session2ASWebAuthenticationSession with Universal LinksCustom Tabs or AppAuth with verified App LinksRedirect URIs registered with your identity provider change, so the provider configuration changes with the app.Proposed
react-native-purchases2StoreKit 2, or the RevenueCat iOS SDKPlay Billing, or the RevenueCat Android SDKKeeping RevenueCat carries customers and entitlements across. Dropping it makes this high.Proposed
expo-notifications5UNUserNotificationCenter with APNsFirebaseMessagingService with NotificationCompatExpo 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-updates5No 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
Difficulty and mappings from the Migration Pack's module map, which the assessment script and this table are generated from. Last checked 22 October 2026.

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.

Run the migration with your own agent

The Migration Pack gives your coding agent the skills, reviewers and scripts to move a React Native app to native one checkpoint at a time.