How to migrate from App Center CodePush: a step-by-step guide
App Center and react-native-code-push are retired and archived. Here is how to swap the SDK, keep your install modes, and release, roll out and roll back from the CLI and CI.
Microsoft retired Visual Studio App Center on 31 March 2025, and its hosted CodePush service went with it. On 20 May 2025 Microsoft also archived the react-native-code-push SDK and the standalone code-push-server on GitHub; both are now read-only. The SDK's README says it won't support React Native's New Architecture.
If your app still ships react-native-code-push with App Center deployment keys, it can't get new updates. This guide moves an existing CodePush setup to a maintained replacement, step by step, while keeping what your team already relies on: install modes, mandatory releases, staged rollouts, rollback, and releases from CI.
We use CodeOnAir CodePush in the examples because its SDK and CLI were built to feel like the originals. If you're still choosing, read our comparison of CodePush alternatives in 2026 first.
What changes and what stays the same
Most of your JavaScript stays as it is. The work is in the package name, the native bundle hook, a new store build, and the CLI commands in your pipeline.
| App Center CodePush | CodeOnAir CodePush | |
|---|---|---|
| Package | react-native-code-push |
@codeonair/code-push-react-native |
| JS API | codePush.sync(), codePush(App), InstallMode, SyncStatus |
The same names |
| Deployment key | CodePushDeploymentKey in strings.xml / Info.plist |
The same keys, plus the server URL |
| Native bundle hook | CodePush.getJSBundleFile() / [CodePush bundleURL] |
CodeOnAirCodePush.getJSBundleFile(...) / CodeOnAirCodePush.bundleURL() |
| Deployments | Named per app, usually Staging and Production | One per platform: <app>-android and <app>-ios |
| CLI | appcenter codepush release-react -a owner/app -d Production |
codeonair-codepush release-react <app>-android |
| New Architecture | Not supported | Supported, including bridgeless, on React Native 0.70+ |
Before you start: write down your current setup
Spend ten minutes on this. It makes the rest mechanical.
- Install modes. Search your code for
codePush.sync(andcodePush(and noteinstallMode,mandatoryInstallMode,checkFrequency,minimumBackgroundDurationand anyupdateDialog. - Binary versions in the wild. Which store versions do most of your users run? Releases target these.
- Deployments. If you used Staging and Production, decide how you'll test before release (see "Staging builds" below).
- CI scripts. Find every
appcenter codepushcommand in your pipelines.
Step 1: Create the deployments and get the keys
In the CodeOnAir dashboard, open your app, go to CodePush, and click Set up CodePush. That creates one deployment for Android and one for iOS, each with its own deployment key (cpd_…). The CLI names them after your app's subdomain, for example shop-android and shop-ios.
Step 2: Swap the SDK
npm uninstall react-native-code-push
npm install @codeonair/code-push-react-native
cd ios && pod install
Then change the import everywhere you use it:
// Before
import codePush from "react-native-code-push";
// After
import codePush from "@codeonair/code-push-react-native";
Step 3: Point the native config at the new deployment
Replace the old App Center keys with the new deployment keys, and add the server URL:
<!-- android/app/src/main/res/values/strings.xml -->
<string moduleConfig="true" name="CodePushDeploymentKey">cpd_…</string>
<string moduleConfig="true" name="CodePushServerUrl">https://app.codeonair.com</string>
<!-- ios/<App>/Info.plist -->
<key>CodePushDeploymentKey</key>
<string>cpd_…</string>
<key>CodePushServerURL</key>
<string>https://app.codeonair.com</string>
Step 4: Load the update's bundle
This is the one native code change. On recent React Native versions (getDefaultReactHost in MainApplication.kt):
// MainApplication.kt
import com.codeonair.codepush.CodeOnAirCodePush
getDefaultReactHost(
context = applicationContext,
packageList = PackageList(this).packages,
jsBundleFilePath = CodeOnAirCodePush.getJSBundleFile(applicationContext),
)
On React Native 0.70 to 0.76, where MainApplication has a DefaultReactNativeHost, override getJSBundleFile() instead:
override fun getJSBundleFile(): String? = CodeOnAirCodePush.getJSBundleFile(applicationContext)
On iOS, return the CodePush bundle in release builds only, so debug builds still load from Metro:
// AppDelegate.swift
import CodeOnAirCodePush
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
CodeOnAirCodePush.bundleURL()
#endif
}
Apps with an Objective-C AppDelegate.mm return [CodeOnAirCodePush bundleURL] in the same place. The project setup docs show every variant.
Step 5: Keep your install modes
The SDK uses the same option names, so your existing sync() call usually works unchanged:
import { useEffect } from "react";
import codePush from "@codeonair/code-push-react-native";
export default function App() {
useEffect(() => {
// Every launch: confirm the running update, then check for a new one.
codePush.sync({
installMode: codePush.InstallMode.ON_NEXT_RESTART,
mandatoryInstallMode: codePush.InstallMode.IMMEDIATE,
});
}, []);
// …
}
If you used the wrapper, that works too: export default codePush(App), or codePush({ checkFrequency: codePush.CheckFrequency.ON_APP_RESUME })(App).
| Install mode | When the update runs |
|---|---|
ON_NEXT_RESTART |
The next time the app is started. The default for optional updates. |
IMMEDIATE |
Right away: the app restarts once the update is downloaded. The default for mandatory updates. |
ON_NEXT_RESUME |
When the app next comes back from the background. |
ON_NEXT_SUSPEND |
While the app is in the background, so the user comes back to the new version. |
minimumBackgroundDuration (in seconds) makes the resume and suspend modes wait until the app has been in the background at least that long. updateDialog shows a confirmation alert before installing, as before. Our post on CodePush install modes covers which mode to pick.
Keep calling sync() (or the wrapper) on every launch. It confirms that the running update started. If a new update crashes before that confirmation, the next launch goes back to the previous bundle, the failure is reported, and that update isn't offered to the device again.
Step 6: Ship one store release
The new SDK is native code, so it reaches users through a normal App Store and Play Store release. Until a user installs that build, their phone still runs the old SDK with App Center keys and can't receive updates from the new server.
Before you submit, test a release build on a real device:
- Build in release mode:
npx react-native run-android --mode release, or the Release scheme in Xcode. - Release a visible change (Step 7) to that build's version.
- Open the app, close it, open it again. The change should be there, and the release page in the dashboard shows the download and install.
Updates only apply in release builds. In debug builds React Native loads JavaScript from Metro and CodePush stays out of the way.
Step 7: Release from the CLI
The CLI is in beta. Once your workspace has access, install it and log in with an access key from Account → CLI access keys:
npm install -g @codeonair/code-push-cli@beta
codeonair-codepush login
codeonair-codepush app list
Then release:
# Optional update for app version 1.4.0
codeonair-codepush release-react shop-android -t 1.4.0 -d "Fix the cart badge"
# Mandatory fix for every 1.4.x build
codeonair-codepush release-react shop-ios -t 1.4.x --mandatory -d "Fix checkout crash"
release-react bundles the app (react-native bundle, or expo export:embed for Expo apps), compiles Hermes bytecode when your app uses Hermes, zips the result and uploads it.
Watch the flags when you copy old scripts. In the App Center CLI, -d meant the deployment name. Here -d is the description, and the deployment is the first argument. A copied -d Production would become your release notes.
| App Center CLI | CodeOnAir CLI |
|---|---|
-a owner/app -d Production |
shop-android (first argument) |
--description "…" |
-d "…" or --description "…" |
-t 1.4.0 / --target-binary-version |
-t 1.4.0 or --targetBinaryVersion 1.4.0 |
-m / --mandatory |
-m / --mandatory |
-r 20 / --rollout 20 |
-r 20 / --rollout 20 |
-x / --disabled |
-x / --disabled |
If you leave out -t, the CLI reads the version from build.gradle or your Xcode project. If your version comes from a variable, pass -t yourself. A release's target version can't be changed later; to reach another version, release again with a new -t.
Step 8: Use staged rollouts and rollback
Send risky changes to a share of devices first:
codeonair-codepush release-react shop-android -t 1.4.0 -r 20 -d "New checkout flow"
codeonair-codepush history shop-android # installs and failures per release
codeonair-codepush patch shop-android -r 100 # widen it to everyone
A device stays in or out of a partial rollout consistently, and a rollout can only grow, so devices that already have the update keep it.
When a release is bad:
codeonair-codepush patch shop-android --label v8 -x true # stop offering v8
codeonair-codepush rollback shop-android # back to the previous release
codeonair-codepush rollback shop-android --targetRelease v6
A rollback adds a new release that carries the earlier bundle, so devices move back on their next update check. The releases explained page walks through what each command does on users' phones.
Step 9: Move CI over
In CI, skip login. Create a separate access key for the pipeline, store it as a secret, and pass it as CODEONAIR_ACCESS_KEY:
# .github/workflows/codepush.yml
name: CodePush
on:
push:
branches: [main]
jobs:
release:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm install -g @codeonair/code-push-cli@beta
- run: codeonair-codepush release-react shop-android -t 1.4.0 -r 20 -d "${{ github.sha }}"
env:
CODEONAIR_ACCESS_KEY: ${{ secrets.CODEONAIR_ACCESS_KEY }}
Using the commit SHA as the description maps every release to code. The CLI skips its update check when CI is set. More examples, including GitLab, are in the CI/CD docs.
Staging builds
There is one deployment per platform per app, not a Staging and a Production deployment. If you need to test updates on internal builds before users see them, two options work:
- A second app for staging. Create a separate app in your workspace (for example
shop-staging) and put its deployment keys in your internal build. Releases to it never reach store users. - A small rollout. Release to production with
-r 5or-r 10, watchhistoryfor failures, then widen it.
What CodePush can't change
Over-the-air updates cover your JavaScript bundle and its images and assets. New native modules, new permissions or changes to native code still need a store release. Both Apple and Google allow interpreted code updates as long as they don't change the app's primary purpose.
FAQ
Can I keep using react-native-code-push with a different server?
The package still installs from npm, but Microsoft archived the repository in May 2025 and says it won't support the New Architecture. You'd be maintaining a fork yourself. Moving to a maintained SDK is less risky in the long run.
Do users need to update the app from the store?
Yes, once. The new SDK is native code, so it ships in a store build. After that, JavaScript updates go over the air again.
Does it work with Hermes and Expo?
Yes. The CLI compiles Hermes bytecode when your app uses Hermes (override with --useHermes false), and bundles Expo apps with expo export:embed. Expo apps need a development build or prebuild, because the SDK has native code.
What happens to updates I released on App Center?
They stay on the devices that already installed them until those users install your new store build. That build starts from the bundle inside it.
How is CodePush priced?
By update downloads per month: 5,000 on Free, 25,000 on Starter, 500,000 on Growth and 2,000,000 on Scale. Past the allowance, apps keep the bundle they have until next month. See pricing.
Move your CodePush setup
CodeOnAir CodePush gives you the same SDK API, a CLI for CI, staged rollouts and crash rollback. The quick start takes about ten minutes. Sign-ups are currently by invitation, so request access and we'll help you move.