Disclosure: I maintain react-native-update, an open-source OTA SDK for React Native, and the hosted service behind it, Cresc. This guide is written for teams that have already decided to leave CodePush. If you're still deciding, I list the other options honestly at the end.
App Center was retired on March 31, 2025, and the react-native-code-push repository is now archived. If your app still calls codePush.sync(), those checks have nowhere to go: the app keeps running whatever bundle it has and stops getting updates.
This guide moves a React Native app from CodePush to react-native-update. Your release workflow stays the same: ship JS and asset changes without a store review, roll out gradually, and roll back if something breaks. On a typical RN 0.7x+ app the code changes take an afternoon.
1. How the concepts map
Most of the work is translating concepts, so here's the cheat sheet first:
| CodePush | react-native-update | Notes |
|---|---|---|
App Center app (owner/MyApp-iOS) |
App per platform (cresc createApp --platform ios) |
Same idea: one app record per platform |
Deployment key in Info.plist / strings.xml
|
appKey in update.json
|
update.json is generated by the CLI; commit it |
Staging deployment |
Test via QR code / deep link before binding | Scan a QR in the dashboard to load an unreleased version on a test device |
Production deployment |
Binding a version to native package(s) | Users get an update only once it's bound to their native version |
--target-binary-version "~1.1.0" |
--packageVersionRange "~1.1.0" |
Both semver ranges |
--rollout 25 |
--rollout 25 |
Same name. Bumping to 100 promotes to full |
appcenter codepush release-react |
cresc bundle --platform ios |
Builds, publishes and (optionally) binds in one step |
codePush(options)(App) |
<UpdateProvider client={...}> |
|
checkFrequency: ON_APP_START / ON_APP_RESUME / MANUAL |
checkStrategy: "onAppStart" / "onAppResume" / null |
"both" (the default) does both |
installMode: ON_NEXT_RESTART |
updateStrategy: "silentAndLater" |
|
installMode: IMMEDIATE |
updateStrategy: "silentAndNow" |
|
updateDialog: true |
updateStrategy: "alwaysAlert" |
|
codePush.notifyAppReady() |
markSuccess() (automatic by default) |
Unmarked updates roll back on next launch |
appcenter codepush rollback |
Pause, then re-bind the previous version | Dashboard or cresc update
|
The biggest difference: there are no deployments. A published version does nothing until you bind it to one or more native versions, optionally at a rollout percentage. You do your testing before binding, using the QR code.
2. Swap the packages
npm uninstall react-native-code-push
npm i react-native-update
npm i -g react-native-update-cli
cd ios && pod install
On RN 0.60+ autolinking handles the native module. Then delete the CodePush config:
-
ios/<App>/Info.plist: removeCodePushDeploymentKey(andCodePushServerURLif you self-hosted) -
android/app/src/main/res/values/strings.xml: removeCodePushDeploymentKey -
android/app/build.gradle/settings.gradle: remove any manualcodepush.gradle/:react-native-code-pushlines if you added them for an old RN version
3. Point the native bundle URL at the new SDK
This is the same hook CodePush used, with a different provider.
iOS, Objective-C (AppDelegate.mm):
- #import <CodePush/CodePush.h>
+ #import "RCTPushy.h"
- (NSURL *)bundleURL
{
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
- return [CodePush bundleURL];
+ return [RCTPushy bundleURL];
#endif
}
On RN < 0.74 the method is sourceURLForBridge: instead of bundleURL; the change is the same.
RN 0.88+: the iOS template moved to the UIScene life cycle, so bundleURL() now lives in SceneDelegate.swift instead of AppDelegate. Make the change below there. If you upgraded to 0.88 with the Upgrade Helper and moved the entry point, carry RCTPushy.bundleURL() over too, or Release builds keep loading the embedded bundle and updates never apply.
iOS, Swift (AppDelegate.swift, or SceneDelegate.swift on RN 0.88+):
+ import react_native_update
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
- CodePush.bundleURL()
+ RCTPushy.bundleURL()
#endif
}
Android, Kotlin, RN ‚â§ 0.81 (MainApplication.kt):
- import com.microsoft.codepush.react.CodePush
+ import cn.reactnative.modules.update.UpdateContext
override val reactNativeHost: ReactNativeHost =
object : DefaultReactNativeHost(this) {
- override fun getJSBundleFile(): String = CodePush.getJSBundleFile()
+ override fun getJSBundleFile(): String? =
+ UpdateContext.getBundleUrl(this@MainApplication)
// ...
}
Android, Kotlin, RN 0.82+ uses ReactHost, so pass the path as a parameter:
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList = PackageList(this).packages,
jsBundleFilePath = UpdateContext.getBundleUrl(this),
)
}
Two Android details that bite people:
- If you use
react-native-screens(react-navigation does), registerRNScreensFragmentFactoryinMainActivity.onCreate. Without it you can get a blank screen after an OTA restart. - Set
crunchPngs falsein the release build type. PNG crunching makes binary diffs unpredictable and the patches bigger.
Expo (SDK 50+, after npx expo prebuild): with Expo 48+ and react-native-update ‚â• 10.28.2 the bundle URL is configured automatically, so you can skip this section. Don't keep expo-updates installed alongside it, because the two conflict.
4. Replace the JS wrapper
Create the app records once. This writes update.json, which holds the appKey for each platform:
cresc login
cresc createApp --platform ios
cresc createApp --platform android
Then swap the wrapper. A typical CodePush setup:
import codePush from "react-native-code-push";
const codePushOptions = {
checkFrequency: codePush.CheckFrequency.ON_APP_RESUME,
installMode: codePush.InstallMode.ON_NEXT_RESTART,
};
export default codePush(codePushOptions)(App);
becomes:
import { Platform } from "react-native";
import { Cresc, UpdateProvider } from "react-native-update";
import updateConfig from "./update.json";
const client = new Cresc({
appKey: updateConfig[Platform.OS].appKey,
checkStrategy: "onAppResume",
updateStrategy: "silentAndLater",
});
export default function Root() {
return (
<UpdateProvider client={client}>
<App />
</UpdateProvider>
);
}
If you had a custom update dialog built on codePush.sync() callbacks, set updateStrategy: null and drive your UI from the useUpdate() hook:
import { useUpdate } from "react-native-update";
function UpdateBanner() {
const { updateInfo, downloadUpdate, switchVersion, switchVersionLater, progress } =
useUpdate();
if (!updateInfo?.update) return null;
// updateInfo.name / description / metaInfo come from the published version.
// `if (await downloadUpdate()) await switchVersion()`: it resolves to false
// when the download didn't complete, so check it before offering a restart.
// progress.received / progress.total drive a progress bar.
// ...
}
Mandatory updates (CodePush's -m) don't have a dedicated flag. Either use updateStrategy: "silentAndNow", or publish with --metaInfo '{"mandatory":true}' and branch on JSON.parse(updateInfo.metaInfo) in your own UI (metaInfo arrives as a string). There's a worked example in the docs.
This covers the common options. For the rest (ON_NEXT_RESUME, disallowRestart, getUpdateMetadata, clearUpdates, restartApp and the App Center CLI commands), see the full CodePush API mapping.
Rollback safety: what replaces notifyAppReady()
CodePush rolled back an update if notifyAppReady() wasn't called after it launched. react-native-update works the same way through markSuccess(), but the provider calls it for you 1 second after mount (autoMarkSuccess: true by default). If the new bundle crashes before that, the next launch rolls back to the previous version and reports a rollback event.
If your app does critical work after the first frame (navigation restore, remote config), mark success later instead:
const client = new Cresc({
appKey,
autoMarkSuccessDelayMs: 5000,
// or return false to skip marking this launch:
healthCheck: async () => remoteConfigLoaded(),
});
5. Upload the native baseline
This step has no CodePush equivalent, and it's what makes the patches small. Updates are shipped as binary diffs against the exact build users have installed, so upload the same artifact you send to the stores:
cresc uploadIpa build/MyApp.ipa
cresc uploadApk android/app/build/outputs/apk/release/app-release.apk
# or, if you distribute an AAB:
cresc uploadAab android/app/build/outputs/bundle/release/app-release.aab
The native version (CFBundleShortVersionString / versionName) becomes the package version that your --packageVersionRange targets. Bump it on every native rebuild. Two different builds with the same version number still work, but you lose diff reuse and the patches get bigger.
6. Publish from CI
Create an API key in the dashboard (Write permission is enough), store it as CRESC_API_TOKEN, and run the CLI. It detects CI's missing TTY and runs non-interactively; --no-interactive makes that explicit. Passing a native range and a rollout publishes and binds in one step:
# .github/workflows/ota.yml
name: OTA release
on:
workflow_dispatch:
inputs:
rollout:
description: "Rollout percentage"
default: "10"
jobs:
publish:
runs-on: ubuntu-latest
strategy:
matrix:
platform: [ios, android]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- run: npm ci && npm i -g react-native-update-cli
- name: Bundle, publish and bind
env:
CRESC_API_TOKEN: ${{ secrets.CRESC_API_TOKEN }}
run: |
cresc bundle --platform ${{ matrix.platform }} \
--name "${GITHUB_SHA::7}" \
--description "OTA release ${GITHUB_SHA::7}" \
--packageVersionRange ">=2.0.0 <3.0.0" \
--rollout ${{ inputs.rollout }} \
--no-interactive
The CodePush equivalent was roughly appcenter codepush release-react -a owner/MyApp-iOS -d Production -t ">=2.0.0 <3.0.0" --rollout 10.
Two behaviors you get without extra configuration:
- On Hermes apps, the CLI compiles each release against an earlier bytecode of the same app and applies a Hermes-aware transform before diffing. In our benchmark a one-line fix ships as a 3.4 KB patch, where a plain bsdiff of the same change is 93.7 KB.
- The source map is archived with each version, so JS crashes from that release show up symbolicated in the dashboard.
7. Test, roll out, roll back
-
Before binding, enable "Use Deep Link" in the dashboard for your app's URL scheme and scan the version's QR code on a test device. That loads the exact unreleased bundle, which replaces what you used
Stagingfor. (The Android activity needslaunchMode="singleTask"and an intent filter for the scheme.) - Bind at a low percentage. Buckets are a stable hash of the device ID, so the same users stay in the canary as you widen it. Each native version can have one canary and one full release at the same time.
- Watch the version's health in the dashboard: download failures, patch failures, rollbacks and symbolicated JS errors.
- Promote by raising the rollout to 100, which replaces the previous full release.
- If it's bad, pause the native package's updates and re-bind the previous version, or revert in git and publish a new version. Devices that crashed on launch have already rolled themselves back.
- If JS is dead on some devices (white screen, crash before the update check runs), publish the fix with Force Boot. Since 10.52.1 the SDK runs a native cold-start check that doesn't depend on JS, so those devices can still pull the fix.
8. The transition period
- Users on old binaries keep their last CodePush bundle. Nothing breaks, but they won't get updates until they install the new store build. Prioritize getting the first react-native-update build out.
- You can't send an OTA to CodePush users. The migration needs one native release. Plan it like any store submission.
- If you self-hosted a CodePush server, you can keep it running for old binaries while new binaries use react-native-update. The two don't interact.
- Once nobody is on the old binaries, delete the App Center tokens from CI and remove the CodePush server.
Other options, honestly
- Self-host CodePush. Before App Center retired, Microsoft open-sourced a standalone CodePush server. If you'd rather run your own infrastructure and keep the existing SDK, this is the smallest code change. Note that the server and the client SDK are both archived now, so you take on hosting and maintaining both.
- Expo Updates (EAS Update). A great fit if you already use EAS for builds. It ships full bundles instead of diffs and bills by update MAU and bandwidth, which matters at scale.
- react-native-update / Cresc. Hermes-aware binary diffs, staged rollouts, automatic rollback and built-in crash symbolication. The SDK, CLI and admin dashboard are open source and there's a free tier.
Questions about a specific setup (brownfield, monorepo, custom bundle names) are welcome in the comments.
Top comments (0)