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.

    eas.json
    {
      "build": {
        "staging": {
          "distribution": "internal",
          "env": { "APP_ENV": "staging" }
        },
        "production": {
          "env": { "APP_ENV": "production" }
        }
      },
      "submit": { "production": {} }
    }
    app.config.js
    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.

    .github/workflows/eas-build.yml
    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-wait

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

    registerForPush.ts
    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:

    scripts/verify-production-config.mjs
    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);
    package.json
    "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.