May 12, 2026 · 7 min read
Migrating Jollin to Expo
A practical look at moving a React Native app to Expo, replacing native project ownership with Expo config, and turning a legacy navigation layer into file-based routing.
I recently migrated Jollin from a classic React Native setup to Expo. It was not a simple "install Expo and continue" change. The migration touched the entry point, native configuration, routing, storage, media handling, maps, camera, release builds, and a lot of small UI assumptions that had quietly grown around the old setup.
The final diff was large, but the real work was not just changing packages. The important part was deciding what Expo should own, what the app should still own, and where old abstractions were hiding behavior.
Why I wanted to migrate
The old app was a regular React Native project with ios/ and android/ committed to the repository. That gave me full native control, but it also meant every native dependency, build setting, icon, splash asset, signing setting, and platform detail lived directly in the repo.
That works for a while, but it gets heavy. Native project files become part of every upgrade. Small dependency changes can turn into Gradle, Podfile, Xcode, and asset cleanup work. I wanted the project to move closer to a config-driven model where native projects could be generated when needed and where more of the platform setup lived in one readable place.
Expo was a good fit because the app could keep using native capabilities while moving configuration into app.config.ts, builds into EAS, and navigation into Expo Router.
Step 1: Make Expo the owner of the app entry
The first visible change was small:
import 'expo-router/entry'
That replaced the old AppRegistry.registerComponent entry point. It looks tiny, but it changes the shape of the app. Instead of mounting one root App.tsx and letting a custom navigation tree take over, Expo Router becomes the entry and the route files under src/app become the source of truth.
I also moved the Babel and Metro setup to Expo-friendly versions:
presets: ['babel-preset-expo']
and:
const { getDefaultConfig } = require('expo/metro-config')
The custom SVG transformer and Reanimated Metro wrapper stayed, but they were adapted to work with Expo's config instead of the old React Native Metro config.
This was the first lesson of the migration: keep the custom parts, but make them plug into Expo's defaults instead of rebuilding the defaults yourself.
Step 2: Move native configuration into app.config.ts
The biggest structural change was deleting the committed native projects and replacing scattered native settings with Expo config.
The new app.config.ts now owns things like:
- app name, slug, icon, scheme, version, and orientation
- Android package name and version code
- iOS bundle identifier, build number, and Apple team id
- splash screen configuration
- custom fonts
- camera permissions
- file sharing support
- Google Maps API keys
- app schemes used by sharing integrations
- EAS project metadata
I also added an .env.release.example and made release values configurable through environment variables. Instead of hardcoding version numbers and native ids in multiple platform files, the app can now read values like APP_VERSION, ANDROID_VERSION_CODE, IOS_BUILD_NUMBER, ANDROID_APPLICATION_ID, and IOS_BUNDLE_IDENTIFIER.
One thing that still needed custom work was release signing. For that I added a local Expo config plugin, plugins/withAndroidReleaseSigning.js. The plugin injects Android release signing configuration and also syncs iOS project version values during prebuild. This kept the migration Expo-native without pretending the app had no custom release requirements.
That was the second lesson: Expo config does not mean "no native concerns." It means native concerns should become explicit, repeatable configuration.
Step 3: Replace native packages with Expo modules
After the app could start through Expo, I started replacing packages that were either too native-heavy or had better Expo equivalents.
Some examples:
react-native-fast-imageusage moved toexpo-image- image selection moved to
expo-image-picker - document picking moved to
expo-document-picker - file handling moved to
expo-file-system - geolocation moved to
expo-location - gradients moved to
expo-linear-gradient - blur effects moved to
expo-blur - camera and QR flows moved to
expo-camera - secure local values moved behind an
expo-secure-storewrapper
This part was not hard because of one big API difference. It was hard because these packages appear everywhere. Images were in notifications, event cards, club cards, carousels, ticket screens, upload components, and profile screens. A migration like this is less like swapping a library and more like replacing plumbing in a building while people are still using every room.
The storage change was a good example. Instead of letting the rest of the app care about the implementation, I added a small storage wrapper around expo-secure-store. That kept most call sites simple and made the storage decision easier to revisit later.
Step 4: Move from navigation names to file-based routes
The hardest part of the migration was routing.
The old app used a React Navigation tree with route names like LoginCard, MapViewerPage, and ClubPastEvents. During the migration, I first had a compatibility layer that mapped those legacy names to Expo Router hrefs.
That helped the app move gradually, but it also became dangerous. The compatibility layer did more than navigate. It also carried hidden side effects:
- opening login while remembering where the user should return after auth
- passing map selection callbacks through route params
- sending image arrays into the image viewer
- copying past event data into the event stepper
Those are not really routing concerns. They are product flows.
So I split the router work into phases:
- Consolidate bootstrap and auth routing.
- Move simple call sites to
useRouter,router.push,router.replace, anduseLocalSearchParams. - Rewrite stateful flows so the state is explicit and feature-owned.
- Delete the legacy bridge completely.
The new app has file-based routes under src/app, including groups for home, guest, club admin, search, payment, tickets, posts, events, and shared routes.
For routes that need params, I added a small hrefs helper:
hrefs.eventDetail(id)
hrefs.clubDetail(clubId)
hrefs.paymentCheckout(bulkId)
hrefs.mapViewer({ lat, lon, preview, searchEnabled })
This gave the app typed, href-first navigation without recreating the old route-name system.
Step 5: Make hidden flows explicit
The best refactor in the migration was src/router/flow.store.ts.
Instead of hiding important behavior inside a navigation wrapper, the app now has explicit flow state for:
- pending auth return hrefs
- map selection results
- image viewer payloads
- past event copy state
For example, opening the login card from a guest-only action now intentionally captures the current href before routing to /login-card. Opening the map viewer intentionally starts a map selection flow before pushing /map-viewer. The image viewer reads from a clear payload instead of depending on route params that may or may not contain a large image array.
This made the app easier to reason about. Navigation now navigates. Product flows own their state.
Step 6: Rebuild the tab and modal structure
Expo Router also changed how the app expresses layout.
The root layout now wraps the app with the theme provider, gesture handler, keyboard provider, toaster, haptics config, and the main stack. Modals and sheets are described directly in the route stack:
/login-cardas a form sheet/event-preview/[id]as a form sheet/map-vieweras a form sheet/image-vieweras a form sheet/payment/checkout/[bulkId]as a modal
The home and guest areas use NativeTabs, so tab structure now lives beside the route files. This made the route tree much easier to scan because layout, route, and presentation became local to src/app.
Things that surprised me
The migration was noisy. Removing ios/, android/, old assets, patches, and old lockfiles made the diff look scarier than the architectural change really was. If I did it again, I would try to separate "dependency and native cleanup" from "router migration" more aggressively.
The second surprise was how many app flows were pretending to be navigation. Login return behavior, image payloads, map callbacks, and copy-from-past-event all looked like route params at first. They were actually workflow state.
The third surprise was that UI polish came along for the ride. Once routes and native modules changed, components like event cards, club cards, search screens, sheets, image viewers, and upload flows needed cleanup too. A migration creates a lot of small opportunities to simplify old assumptions.
What I would recommend
If you are planning a similar migration, I would not start by deleting everything.
Start by making Expo boot the app. Then move native configuration into app.config.ts. After that, replace native dependencies one category at a time. Only then attack routing.
For routing, do not migrate only by search and replace. First make an inventory of every route that carries special behavior. Simple screens can move quickly. Stateful flows need design.
The rule that helped me most was this:
If a route param is carrying behavior instead of data, it probably deserves its own flow state.
By the end of the migration, Jollin was running through Expo, using Expo Router, building with EAS configuration, and relying less on committed native project files. The codebase is not magically finished, but the foundation is much cleaner.
The app now feels easier to upgrade, easier to reason about, and less tied to platform files that should be generated rather than hand-maintained.
That was the real win of the Expo migration. Not just fewer native files, but fewer hidden rules.