3 min read
Moving four live React Native apps to Expo, one at a time
How I migrated four production React Native CLI apps at Flabs to Expo: generated native projects, EAS builds and credentials, expo-notifications, and the staging build that reached production.
At Flabs I owned four production React Native apps: a lab app (LIMS), a doctor app, a partner app and a field app for phlebotomists. They were built on the React Native CLI, and they were already live on the App Store and Google Play when we moved them to Expo. This is the setup we ended up with, the order we did it in, and the one mistake that reached production.
One app at a time, starting with LIMS
We migrated one app at a time, and each one was released before the next one started. LIMS went first and took about two weeks.
No more ios/ and android/ in the repo
The native projects are now generated rather than committed. expo prebuild writes ios/ and android/ from the app config, so the repo holds only the JavaScript and that config. Expo calls this Continuous Native Generation.
When something native needs debugging, I still run prebuild locally and build the generated project with Xcode or Gradle. The folders just never get checked in.
Keep the signing identity
A live app has to keep signing with the identity its store listing already knows. For each app, I uploaded the existing Android keystore and iOS certificates to EAS credentials, so EAS builds sign exactly as the old local builds did.
Build profiles and environments
Each app has staging and production build profiles in eas.json. Environment-specific values come from app.config.js and EAS environment variables, not from hand-edited native files.
{
"build": {
"staging": {
"distribution": "internal",
"env": { "APP_ENV": "staging" }
},
"production": {
"env": { "APP_ENV": "production" }
}
},
"submit": { "production": {} }
}const APP_ENV = process.env.APP_ENV ?? "staging";
const API_URL = {
staging: "https://staging-api.example.com",
production: "https://api.example.com",
}[APP_ENV];
export default ({ config }) => ({
...config,
extra: { ...config.extra, appEnv: APP_ENV, apiUrl: API_URL },
});From local builds to EAS, triggered by GitHub Actions
Builds started out local. Release builds then moved to EAS's cloud builders, triggered by GitHub Actions workflows. Local EAS builds (eas build --local) and native builds stayed in use for debugging.
The first store uploads were manual. Once the pipeline settled, EAS Submit handled routine releases.
name: EAS production build
on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: npm ci
- run: eas build --platform all --profile production --non-interactive --no-waitPush notifications: from react-native-firebase to expo-notifications
The apps used @react-native-firebase/messaging. As part of the migration they moved to expo-notifications and Expo's push service: the FCM and APNs credentials live in EAS, and each app registers for an Expo push token.
import Constants from "expo-constants";
import * as Device from "expo-device";
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
export async function registerForPush(): Promise<string | null> {
if (!Device.isDevice) return null;
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "Default",
importance: Notifications.AndroidImportance.DEFAULT,
});
}
const { status } = await Notifications.requestPermissionsAsync();
if (status !== "granted") return null;
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const { data } = await Notifications.getExpoPushTokenAsync({ projectId });
return data; // send to your backend
}Releasing safely
Migrated builds went through internal testing first: TestFlight on iOS, and an internal or closed track on Google Play. And because the apps now shipped with EAS Update, a JavaScript-only fix could go out between store releases if something slipped through. We used it for hotfixes, not features.
The mistake that reached production
We ran separate staging and production environments. On one LIMS release, a misconfigured env file meant the build I uploaded to production was actually a staging build, and users started having trouble logging in. We shipped a corrected build to both stores and asked Apple for an expedited review.
Since then, a production build starts with a local check. It verifies the environment name and the API URL, shows what it found, and the build only starts once I confirm. Something like this:
import { execFileSync } from "node:child_process";
import { createInterface } from "node:readline/promises";
const config = JSON.parse(
execFileSync("npx", ["expo", "config", "--type", "public", "--json"], {
env: { ...process.env, APP_ENV: "production" },
encoding: "utf8",
}),
);
const { appEnv, apiUrl } = config.extra ?? {};
if (appEnv !== "production" || apiUrl !== "https://api.example.com") {
console.error(`Refusing to build: resolved config is ${appEnv} → ${apiUrl}`);
process.exit(1);
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question(`Build production against ${apiUrl}? (y/N) `);
rl.close();
if (answer.trim().toLowerCase() !== "y") process.exit(1);"scripts": {
"build:production": "node scripts/verify-production-config.mjs && eas build --platform all --profile production"
}Where it ended up
Four apps on Expo, with no native folders in the repo. Signing is held in EAS. Builds and submissions run from GitHub Actions through EAS, and EAS Update covers the fixes that can't wait for a store release.