Migrate from EAS Update
Replace expo-updates with @hotcodepush/expo-ota-updates at your next store release, with channels, the runtime version and your signing key mapped.
An Expo app leaves EAS Update by replacing expo-updates with @hotcodepush/expo-ota-updates at its next store release. The store build that carries the new SDK is the switch, and the binaries before it keep taking EAS updates until their users move. What differs, side by side, is on the site's EAS Update alternative page.
HotCodePush serves no EAS-compatible endpoint, so expo-updates cannot be pointed at it. The minimum versions are on Supported versions. An app on a newer Expo SDK than the SDK supports waits until that page lists it.
A bare React Native app on expo-updates starts at the React Native SDK's Installation page instead.
AI-assisted migration (recommended)
Install the HotCodePush skills:
npx skills add hotcodepush-team/skillsThen paste the prompt into your coding agent:
Migrate this app from EAS Update to HotCodePush: install the skills with `npx skills add hotcodepush-team/skills`, then run the hotcodepush-migrate skill, following https://hotcodepush.com/docs/migrate/eas-update.The agent removes expo-updates, runs init, maps channels, the signing key, config keys and SDK calls, and reports a checklist for the store release. It never touches EAS Update, and the first release waits for the store build. Agent skills explains what the agent does.
Prerequisites
- You have a HotCodePush account and an organization.
npx hotcodepush initcreates both, as the quickstart shows. - The app is an Expo app that runs in a development build or a release build, never Expo Go.
- You have the private key file your EAS updates were signed with, if code signing was on. Sign your bundles explains what the key does here.
- A store release lies ahead, and Node.js is on every build machine, since the build step runs the CLI from an Xcode phase and a Gradle task.
Steps
If you already removed expo-updates and ran init, skip to step 4.
1. Freeze a branch for the old builds
git branch eas-update-fleetA bundle built from the migrated code imports a native module the old binaries lack. Hotfixes for them come from a branch that keeps the old client.
2. Remove expo-updates
npm uninstall expo-updatesRemove the updates block and runtimeVersion from the app config, and every expo-updates import per the SDK calls below. Keep extra.eas.projectId while EAS Build makes your binaries. Remove channel from each build profile in eas.json, since step 5 replaces it.
The prebuild refuses a project that lists expo-updates unless updates.enabled is false.
3. Run init
npx hotcodepush initinit signs you in, picks or creates the organization and the app with its production channel, installs @hotcodepush/expo-ota-updates and writes hotcodepush.json. It adds the package to plugins in app.json or app.config.json. A project whose app config is code adds the entry itself.
It asks whether to generate a signing key: answer no if you bring the EAS key, which step 4 adds. Decline the first release too. Pin the CLI with npm install --save-dev hotcodepush: every native build runs it.
Release after the store build, not before
A release created before a binary was built is never served to that binary. The release that reaches your new store builds is the one you create in step 7, after step 6 built them.
The config plugin wires the build step at npx expo prebuild. hotcodepush.json takes over from updates.url, runtimeVersion and the expo-channel-name header: appId and channel are the whole file. The Expo SDK's Configuration page lists every other key.
4. Add your signing key
Skip this step if your EAS updates were not signed. EAS code signing generated an RSA key pair of 2,048 bits in PEM files, which signing-key add takes as it is:
npx hotcodepush signing-key add --private-key-path ../keys/private-key.pemIt registers the public key with the app and appends it to publicKeys in hotcodepush.json. The build step writes the list into the binary, which is what updates.codeSigningCertificate did. The private key stays where it is: every upload names it with --private-key-path, or CI sets the file's content as HOTCODEPUSH_SIGNING_KEY.
5. Create the channels your EAS channels had
production exists. Each further EAS channel becomes one here:
npx hotcodepush channel create --name staging --discoverable--discoverable lets the app switch to the channel by name with setChannel({ name }), as Channels describes. Without it the channel is reachable by its id alone.
A build profile that followed staging sets HOTCODEPUSH_CHANNEL under env in eas.json, as Set up environments and tenants shows.
6. Prebuild and ship the store release
npx expo prebuild --cleanThen build the release as before. The build step runs binary create inside the build and registers it, so the machine, EAS Build included, needs HOTCODEPUSH_TOKEN in the build's environment or npx hotcodepush login. Under CI a store build without a token fails, and npx hotcodepush binary list shows the registered builds.
A development build takes no updates: verify in a release build, then run npx hotcodepush doctor.
7. Release from your pipeline
npx hotcodepush release create --bundle-version "$(git rev-parse --short HEAD)" --yesWith HOTCODEPUSH_TOKEN and, for a signing app, HOTCODEPUSH_SIGNING_KEY in the environment, this is the whole pipeline step. The upload runs expo export:embed per platform where it runs, so the EXPO_PUBLIC_* values an EAS environment gave eas update must be in the job's environment. Ship every commit is the pipeline this line sits in.
Open the app on a device that runs the new store build: the release applies within about a minute (How an update reaches a device). If nothing happens, see Troubleshooting.
8. Run both until the fleet moves
A binary carries one client: an EAS update never reaches a HotCodePush build, nor the reverse. Hotfixes for the old builds come from the branch of step 1 with eas update, and their runtime version must match exactly. The Binaries page and binary list show each new build's devices.
Old binaries stay on EAS until their users update
A user who never updates the binary stays on EAS and never receives a HotCodePush release. Once the old builds are below the share you leave behind, run eas channel:pause production: their devices keep their update, and EAS counts no new MAU. Never run eas update:roll-back-to-embedded, which sends them back to older code.
CLI commands
| EAS CLI | HotCodePush |
|---|---|
eas update:configure | hotcodepush init |
eas update --channel production --message "Fixes the cart" | release create --channel production --notes "Fixes the cart". The notes ride the public index, and the commit is recorded on its own. |
eas update --auto | release create --bundle-version "$(git rev-parse --short HEAD)" |
eas update --branch staging | Nothing. A channel has no branch, and release create --from-channel staging --channel production promotes. |
eas update --platform ios | release create --platform ios |
eas update --environment production | Nothing. The bundle is built on the machine that uploads, with its environment. |
eas update --rollout-percentage 25 | release create --rollout-percent 25 |
eas update --private-key-path keys/private-key.pem | release create --private-key-path keys/private-key.pem, or the key's text in HOTCODEPUSH_SIGNING_KEY |
eas update --input-dir dist --skip-bundler | release create --path <dir> takes one platform's expo export:embed output, never an expo export directory. |
eas update --upload-source-maps | Nothing to pass. The upload leaves maps out, and Tag releases in your telemetry uploads them to your tracker. |
eas update:edit --rollout-percentage 50 | release rollout --release 43 --rollout-percent 50, once per platform's number |
eas update:revert-update-rollout | release revoke --release 43, or release pause --release 43 to leave its devices on it, once per platform's number |
eas update:rollback | release rollback --to-release 42, once per platform's number |
eas update:republish --group <id> | release rollback --to-release 12, once per platform's number |
eas update:republish --destination-channel production | release create --from-channel staging --channel production |
eas update:roll-back-to-embedded | release revoke --all |
eas update:list, update:view, update:insights | release list, release get, statistics get --type updates |
eas channel:create, channel:list, channel:view, channel:delete | channel create, channel list, channel get, channel delete |
eas channel:pause, channel:resume | channel pause, channel resume |
eas channel:protect, channel:unprotect | channel update --protected, channel update --protected false |
eas channel:edit --branch, channel:rollout, eas branch:* | Nothing. A channel has no branch, and a rollout belongs to a release. |
EXPO_TOKEN | HOTCODEPUSH_TOKEN in the environment, or hotcodepush login |
expo/expo-github-action with its preview subaction | A run: step runs npx hotcodepush release create (GitHub Actions), and the preview channel workflow replaces the preview. |
A release is numbered per channel and, on Expo, exists per platform: one release create makes two, one per expo export:embed output. The CLI reference lists every command, release and its flags on the release page.
SDK calls
| expo-updates | HotCodePush | Note |
|---|---|---|
Updates.checkForUpdateAsync() | checkForUpdate() | It answers available or up_to_date. Nothing is downloaded. |
Updates.fetchUpdateAsync() | downloadUpdate({ applyStrategy: 'manual' }) | Without the option the update applies per applyStrategy. |
Updates.reloadAsync() | applyUpdate() | It answers nothing_to_apply when no update waits. |
| check, fetch, then reload | sync({ applyStrategy: 'immediate' }) | One cycle gives one typed result. |
useUpdates() | useUpdates() | state is getState(), lastSync the last cycle's result, isSyncing and downloadProgress from 0 to 1. |
currentlyRunning.updateId, createdAt, manifest | getState().currentRelease | It is null on the embedded bundle. |
Updates.channel, Updates.runtimeVersion | getChannel(), getDevice().fingerprint | |
Updates.isEmergencyLaunch | notifyReady().isRolledBack, the updateRolledBack event | |
Updates.setUpdateRequestHeadersOverride() | setChannel({ name }), setChannel(null) | The choice is persisted, and the channel must be discoverable. |
Updates.setUpdateURLAndRequestHeadersOverride() | setChannel({ id }) | There is no other URL and no update id. |
Updates.setExtraParamAsync() | setAttributes() | --attribute conditions match them. |
Updates.readLogEntriesAsync() | getState().lastCheck, showDebugScreen() | |
useUpdateEvents(), addListener(), removed from expo-updates | addListener() | updateAvailable, updateDownloaded, updateFailed, downloadProgress, updateRolledBack |
checkAutomatically: "ON_LOAD" | checkStrategy: auto | This is the default. It also checks on resume and every checkIntervalSeconds, 900 by default. |
checkAutomatically: "NEVER" | checkStrategy: manual | |
checkAutomatically: "ON_ERROR_RECOVERY" | none | manual is the closest. |
checkAutomatically: "WIFI_ONLY" | downloadStrategy: unmetered | EAS gates the check, HotCodePush the download. |
fallbackToCacheTimeout: 0 | applyStrategy: next_start | This is the default in both. |
fallbackToCacheTimeout above 0 | applyStrategy: immediate | It reloads once downloaded and rendered instead of holding the launch screen. |
codeSigningCertificate, codeSigningMetadata | publicKeys in hotcodepush.json | signing-key add fills it, with the same algorithm. |
enableBsdiffPatchSupport | none | Deltas are always on. |
maxUpdatesToKeep | none | The SDK keeps the current, the next and the fallback release. |
useEmbeddedUpdate, useNativeDebug | none |
The Expo SDK's API page has every method, event and type. Choose an apply strategy compares the strategies fallbackToCacheTimeout maps to.
Before and after
The updates block and runtimeVersion leave the app config, and the plugin entry arrives:
{
"expo": {
"runtimeVersion": { "policy": "appVersion" },
"updates": {
"url": "https://u.expo.dev/<projectId>",
"checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 0
},
"plugins": ["@hotcodepush/expo-ota-updates"],
"extra": { "eas": { "projectId": "<projectId>" } }
}
}hotcodepush.json takes the address and the channel:
{
"appId": "<your app id>",
"channel": "production"
}production needs nothing in eas.json, as hotcodepush.json names it. Another profile sets its channel through the environment instead of the channel key:
{
"build": {
"staging": {
"channel": "staging"
"env": { "HOTCODEPUSH_CHANNEL": "staging" }
}
}
}What differs
The fingerprint does not read the app config
The fingerprint hashes the lockfile's native packages and the files under extraFingerprintPaths, never app.json. Expo's fingerprint policy read the app config too, so a changed plugin option or permission moved the runtime version. Here it moves nothing unless app.json is listed in extraFingerprintPaths. Then every edit of the file does, version bumps included.
MAU is counted differently
EAS counts an install that downloaded at least one update in the month, while HotCodePush counts a device that checked for one (Billing and MAU). The two numbers are not comparable.
- Addresses:
updates.urlandprojectIdbecomeappId, which is public, and a channel is addressed by name in the build and by an unguessable id at runtime. - The fingerprint replaces the runtime version: it rides on every release, so a release never lands on a build whose native code differs. Nothing is bumped, and a
--binary-versionrange over the store version narrows it. See Compatibility. - A rollout belongs to a release, not a branch: nothing blocks the next publish, and a newer release supersedes the rollout inside its bucket. A device keeps the release it has, and auto-pause halts one whose failure rate climbs. See Roll out gradually.
- Rollback is a release:
release rollback --to-release <number>publishes an earlier bundle as a new release, mandatory by default, once per platform's number. A full revoke,release revoke --all, returns the channel's devices to the embedded bundle across every native layer. - The readiness gate has no off switch: a release that has not rendered within
readyTimeoutSeconds, 10 by default, is rolled back and never tried there again. The report feeds auto-pause. - Mandatory updates exist:
release create --mandatoryapplies at once undermandatoryApplyStrategy: immediate. - Code signing is on every plan.
Next steps
- Ship every commit has the pipeline that releases every merge with the one CLI line, named by its commit.
- Test with preview channels gives every pull request an expiring channel and a QR code for testers, replacing the development build's preview.
- Roll out gradually: start a release at a small percentage, let it progress and let auto-pause stop a bad one.
- Expo SDK reference documents every
hotcodepush.jsonkey, method, event, type and reason of the SDK you installed.
Last updated on
Migrate from CodePush
Replace the retired CodePush client with @hotcodepush/react-native-code-push at your next native release and keep delta updates and your signing key.
Migrate from Hot Updater
Move a React Native or Expo app from self-hosted Hot Updater to HotCodePush, map every command and call, and retire the bucket and the database.