Migrating from Expo Updates

Step-by-step guide to replacing Expo Updates (EAS Update) with Codemagic CodePush in a React Native app

This guide walks through migrating a React Native app’s OTA update mechanism from Expo Updates / EAS Update to Codemagic CodePush. It covers the conceptual differences between the two systems, maps equivalent concepts, and provides step-by-step instructions for completing the migration.


Why migrate?

Expo Updates (via EAS Update) is tightly integrated with the Expo ecosystem and EAS toolchain. Teams moving to bare React Native, teams already using Codemagic for CI/CD, or teams looking for tighter CLI-based workflow control often find Codemagic CodePush to be a better fit. Key reasons to make the switch include:

  • Single platform — CodePush is hosted by Codemagic, so OTA updates and CI/CD builds live in the same platform.
  • CLI-first workflow — every release, promotion, rollback, and rollout is controlled from the CLI or your CI pipeline, with no dependency on a separate Expo account.
  • React Native (non-Expo) compatibility — CodePush works natively with bare React Native without requiring Expo modules.
  • Deployment model familiarity — the Staging → Production promotion model maps directly to how most CI/CD pipelines already think about environments.

Concept mapping

Before starting the migration, it helps to understand how concepts translate between the two systems.

Expo Updates / EAS UpdateCodemagic CodePushNotes
EAS project / projectIdCodePush app (code-push app add)One registration per platform recommended
Channel (e.g. preview, production)Deployment (e.g. Staging, Production)Same idea; CodePush calls them deployments
BranchDeploymentEAS branches map roughly to CodePush deployments
eas update --channel productioncode-push release-react MyApp-iOS iosBoth bundle and publish JS in one command
Runtime versionTarget binary version (--target-binary-version)Controls which native binary receives a given update
eas update:configurecode-push app add + native file editsCodePush configuration is done in native files
expo-updates package@code-push-next/react-native-code-pushThe client-side SDK
updates.url in app.jsonCodePushServerURL in Info.plist / strings.xmlThe update server endpoint
EAS access tokenCodePush access tokenUsed for CLI authentication
eas update --branch ... promotecode-push promote MyApp Staging ProductionPromotes a validated update to production
--rollout-percentage=10--rollout 25 on release-react or patchSpecifies a percentage of users that should receive a new update

Before you begin

Prerequisites:

  • A React Native app currently using expo-updates / EAS Update for OTA delivery.
  • Node.js ≥ 16 installed.
  • A Codemagic account. Sign up at codemagic.io if you do not have one.
  • A CodePush access token provided by the Codemagic team. Request one here.
Important: CodePush updates are managed entirely through the CLI and CI pipelines — not through the Codemagic web UI. Ensure your team is comfortable with CLI-based workflows before starting.

Step 1 — Install the CodePush CLI

Install the Codemagic CodePush CLI globally:

npm install -g @codemagic/code-push-cli

Verify the installation:

code-push --version

You should see a version number printed. If the command is not found, check that your global npm bin directory is in your PATH.


Step 2 — Authenticate the CLI

Log in using the access token provided by the Codemagic team:

code-push login "https://codepush.pro/" --accessKey $CODEPUSH_TOKEN

Store your access token in a safe place (e.g. a password manager or a secrets manager). You will need it again when setting up CI.


Step 3 — Register your apps on the CodePush server

Create a CodePush app registration for each platform. React Native bundles differ per platform, so separate registrations are required.

code-push app add MyApp-Android
code-push app add MyApp-iOS

Use any naming convention you like, but including the platform name in the app name is strongly recommended for clarity.

When an app is created, CodePush automatically provisions two deployments: Staging and Production. These are the equivalents of your EAS Update channels.

To list apps and confirm registration:

code-push app list

Retrieve deployment keys

Each deployment has a unique deployment key embedded in the app binary. Retrieve them with:

code-push deployment list MyApp-iOS -k
code-push deployment list MyApp-Android -k

You will need these keys in Steps 6 and 7 below.


Step 4 — Remove Expo Updates from the project

4a. Uninstall the package

# npm
npm uninstall expo-updates

# yarn
yarn remove expo-updates

4b. Remove EAS Update configuration from app.json / app.config.js

Remove the updates block and runtimeVersion field from your Expo config:

 {
   "expo": {
     "name": "MyApp",
     "slug": "my-app",
-    "runtimeVersion": {
-      "policy": "fingerprint"
-    },
-    "updates": {
-      "url": "https://u.expo.dev/your-project-id",
-      "enabled": true,
-      "checkAutomatically": "ON_LOAD"
-    }
   }
 }

4c. Remove EAS Update native configuration

Android — AndroidManifest.xml

Remove any expo-updates meta-data tags that were added during EAS configuration:

- <meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL"
-   android:value="https://u.expo.dev/your-project-id" />
- <meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION"
-   android:value="@string/expo_runtime_version" />

iOS — Expo.plist (if present)

If your iOS project contains an Expo.plist file that was added as part of EAS Update, you can remove it, or leave it in place — it will simply be ignored once expo-updates is no longer installed.

4d. Remove eas.json channels (optional)

If you are fully abandoning EAS and no longer need EAS Build, you can remove the channel property from each build profile in eas.json. If you continue to use EAS Build to produce your native binaries, leave the file as-is.


Step 5 — Install the CodePush client SDK

# npm
npm install @code-push-next/react-native-code-push

# yarn
yarn add @code-push-next/react-native-code-push

Step 6 — Configure native projects

iOS — Info.plist

Add the CodePush server URL and your deployment key to ios/<YourApp>/Info.plist:

<key>CodePushServerURL</key>
<string>https://codepush.pro/</string>
<key>CodePushDeploymentKey</key>
<string>YOUR_IOS_DEPLOYMENT_KEY</string>

Replace YOUR_IOS_DEPLOYMENT_KEY with the Staging key for development builds and the Production key for release builds. Use environment variables or build scripts to inject the correct key rather than hardcoding both in the file.

Android — android/app/src/main/res/values/strings.xml

<string moduleConfig="true" name="CodePushServerUrl">https://codepush.pro/</string>
<string moduleConfig="true" name="CodePushDeploymentKey">YOUR_ANDROID_DEPLOYMENT_KEY</string>

As with iOS, use your Staging key for debug/QA builds and your Production key for release builds.

Android — android/app/build.gradle

Add the CodePush Gradle plugin at the bottom of the file:

apply from: "../../node_modules/@code-push-next/react-native-code-push/android/codepush.gradle"

iOS — install CocoaPods

cd ios && pod install && cd ..

Step 7 — Update native bundle loading

CodePush needs to intercept the JS bundle URL so it can serve updated bundles. This requires a small change to your native app delegate.

iOS — Objective-C (AppDelegate.m)

Add the import at the top of the file:

#import <CodePush/CodePush.h>

Replace the existing bundle URL:

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
  #if DEBUG
    return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
  #else
    return [CodePush bundleURL];    // <-- replaces the original bundleURL call
  #endif
}

iOS — Swift (AppDelegate.swift)

Add the import:

import CodePush

Update the bundleURL method:

override func bundleURL() -> URL? {
  #if DEBUG
    RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
  #else
    CodePush.bundleURL()    // <-- replaces Bundle.main.url(...)
  #endif
}

Android — MainApplication.kt (React Native < 0.82)

import com.microsoft.codepush.react.CodePush

class MainApplication : Application(), ReactApplication {
    override val reactNativeHost: ReactNativeHost =
        object : DefaultReactNativeHost(this) {
            // ...
            override fun getJSBundleFile(): String {
                return CodePush.getJSBundleFile()
            }
        }
}

Android — MainApplication.kt (React Native ≥ 0.82)

import com.microsoft.codepush.react.CodePush

class MainApplication : Application(), ReactApplication {
    override val reactHost: ReactHost by lazy {
        getDefaultReactHost(
            context = applicationContext,
            packageList = PackageList(this).packages,
            jsBundleFilePath = CodePush.getJSBundleFile(),
        )
    }
}

Step 8 — Wrap the root component

In your app’s entry point (typically App.tsx or index.js), wrap the root component with the CodePush HOC:

import codePush from '@code-push-next/react-native-code-push';

function App() {
  // your app code
}

export default codePush(App);

This enables the SDK to check for updates automatically on app launch. For advanced update strategies (background downloads, custom dialogs, mandatory update UI), see the Advanced sync options documentation.

Using Expo Router? If your project uses Expo Router, the root of your application is handled differently. Instead of wrapping App.tsx, apply the CodePush HOC to the default export in app/_layout.tsx:

import codePush from '@code-push-next/react-native-code-push';

function RootLayout() {
  return <Stack />;
}

export default codePush(RootLayout);

Step 9 — Validate end-to-end

Before updating your CI pipeline, verify that CodePush is working correctly with a local test release to your Staging deployment.

code-push release-react MyApp-iOS ios --deployment-name Staging
code-push release-react MyApp-Android android --deployment-name Staging

Install the updated development build on a device or simulator, open the app, and confirm that the update is downloaded and applied. Refer to Debugging and common issues if no update is received.


Step 10 — Update your CI/CD pipeline

This is where the migration pays dividends if you are already on Codemagic — the OTA release step lives in the same codemagic.yaml as your regular build.

Codemagic (codemagic.yaml)

Replace any eas update steps with the CodePush equivalents:

scripts:
  # ... your existing build and test steps ...

  - name: Install CodePush CLI
    script: | 
      npm install -g @codemagic/code-push-cli

  - name: Release CodePush update to Staging
    script: | 
      code-push login "https://codepush.pro" --accessKey $CODEPUSH_TOKEN
      code-push release-react MyApp-iOS ios --deployment-name Staging
      code-push release-react MyApp-Android android --deployment-name Staging

Store CODEPUSH_TOKEN as a secure environment variable in your Codemagic project settings — never commit it to the repository.

GitHub Actions (if applicable)

- name: Install CodePush CLI
  run: npm install -g @codemagic/code-push-cli

- name: Release CodePush update to Staging
  env:
    CODEPUSH_TOKEN: ${{ secrets.CODEPUSH_TOKEN }}
  run: | 
    code-push login "https://codepush.pro" --accessKey $CODEPUSH_TOKEN
    code-push release-react MyApp-iOS ios --deployment-name Staging
    code-push release-react MyApp-Android android --deployment-name Staging

Step 11 — Adopt the Staging → Production promotion workflow

The recommended CodePush release workflow mirrors the EAS Update concept of channels but adds an explicit promotion step:

graph LR %% Colors %% classDef red fill:#ed2633,stroke:#FFF,stroke-width:1px,color:#fff RELEASE(Release to Staging) --> TEST(Internal testing and QA) TEST --> PROMOTE(Promote to Production - no rebuild required)

After a Staging release is validated, promote it:

code-push promote MyApp-iOS Staging Production
code-push promote MyApp-Android Staging Production

This ensures the exact tested bundle — not a freshly built one — is what reaches your production users.


Production controls

Once the migration is complete, you can leverage CodePush’s production controls that have no direct equivalent in EAS Update:

Percentage rollouts

Release to a subset of users and increase gradually:

# Release to 20% of users
code-push release-react MyApp-iOS ios --rollout 20

# Increase rollout after monitoring
code-push patch MyApp-iOS Production --rollout 50
code-push patch MyApp-iOS Production --rollout 100

Mandatory updates

Force an update to install immediately (useful for critical bug fixes):

code-push release-react MyApp-iOS ios --mandatory

Rollback

Instantly revert users to the previous working update:

code-push rollback MyApp-iOS Production
code-push rollback MyApp-Android Production

Target binary version

Restrict an update to a specific native app version (the equivalent of EAS Update’s runtime version):

# Deliver only to apps running binary version 1.2.0
code-push release-react MyApp-iOS ios --target-binary-version "1.2.0"

# Deliver to any 1.2.x patch version
code-push release-react MyApp-iOS ios --target-binary-version "~1.2.0"

Common issues after migration

SymptomLikely causeResolution
App does not check for updatesDeployment key not set or wrong server URLVerify CodePushServerURL and CodePushDeploymentKey in native files
Update downloads but never appliesnotifyAppReady() not calledCodePush requires the app to confirm a successful launch; the HOC wrapper handles this automatically when using codePush(App)
CLI authentication failsToken expired or incorrectly setRe-request a token and re-run code-push login
Release reaches wrong usersStaging key used in production buildInject deployment keys via environment variables per build type
pod install fails after adding SDKStale Podfile.lockDelete ios/Podfile.lock and run pod install again

Next steps