@otaupdate/react-native
v2.0.0
Published
Over-the-air JS bundle updates for React Native — bare and Expo
Maintainers
Readme
@otaupdate/react-native
Over-the-air JS bundle updates for React Native. Ships the JS + assets of a release to installed apps without going through the App Store or Play Store.
Works in bare React Native and Expo (via a config plugin). Native code handles download, SHA-256 verification, unzip, bundle swap-in, and rollback-on-failure; JS handles the update check and the app-facing API.
What OTA can and cannot ship. Anything that lives in the JS bundle and its assets: screens, logic, images, styles. Nothing native: a new native module, a permission, an SDK version bump, or an
Info.plistchange still needs a store release. Shipping a JS bundle that calls a native API the installed binary doesn't have will crash the app — usetargetBinaryVersionto keep those releases apart.
Install
npm install @otaupdate/react-native # the SDK, in your app
npm install -g @otaupdate/cli # the CLI, for publishingThen connect the app. ota init runs once: it detects your stack, creates the
project, writes ota.config.js and the ota:* release scripts, and prints one
SDK key per channel (production and staging):
ota login
ota initA key belongs to one channel and covers both platforms — the device reports
its own platform on every check. Put the staging key in staging builds and the
production key in store builds; the channel then travels with the binary, so a
staging build can never pull production updates. The sections below are only
about getting that key into the native build.
One version (1.0.8+) supports RN 0.7x and 0.80+ alike — no version pinning
needed. (1.0.4–1.0.7 briefly split into two version lines while an
@otaupdate/[email protected]-only mechanism was tried for a flash-free
Android install; it depended on an internal RN API whose shape isn't stable
across versions, so it was reverted in favor of the single restart-based
mechanism every version below uses — see the Troubleshooting table.)
Bare React Native
iOS
cd ios && pod installThen point React Native at the OTA bundle. In AppDelegate.swift (RN 0.77+):
import OtaUpdate
override func bundleURL() -> URL? {
#if DEBUG
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
return OtaUpdate.bundleURL() // <- was Bundle.main.url(forResource: "main", …)
#endif
}Or in AppDelegate.mm (RN 0.76 and earlier):
#import <OtaUpdate/OtaUpdate.h>
- (NSURL *)getBundleURL {
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
return [OtaUpdate bundleURL]; // <- was [[NSBundle mainBundle] URLForResource:…]
#endif
}Add the channel key to ios/<YourApp>/Info.plist:
<key>OtaDeploymentKey</key>
<string>ota_live_xxxxxxxxxxxxxxxxxxxxxxxx</string>That is the only required key — the SDK already knows which server to talk to.
(OtaServerUrl exists for internal deployments and should stay unset.)
Android
Autolinking registers the module. Add the one override in
android/app/src/main/java/.../MainApplication.kt:
import com.otaupdate.OtaUpdate
override val reactNativeHost: ReactNativeHost =
object : DefaultReactNativeHost(this) {
override fun getJSMainModuleName(): String = "index"
override fun getJSBundleFile(): String? =
OtaUpdate.getJSBundleFile(applicationContext) // <- add this
// …
}And the same channel key in android/app/src/main/res/values/strings.xml:
<string name="ota_deployment_key" translatable="false">ota_live_xxxxxxxxxxxxxxxxxxxxxxxx</string>Android — newer (bridgeless) template. Some RN versions generate a
MainApplication.kt with no ReactNativeHost/getJSBundleFile() to
override at all — it builds reactHost directly via getDefaultReactHost(...).
If yours looks like this, pass the bundle path as that call's
jsBundleFilePath parameter instead of using an override:
import com.otaupdate.OtaUpdate
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
jsBundleFilePath = OtaUpdate.getJSBundleFile(applicationContext), // <- add this
packageList =
PackageList(this).packages.apply {
// add(MyReactNativePackage())
},
)
}The strings.xml configuration above is unchanged either way. Mandatory/resume
installs on this template restart the process to apply the update (see
Platform verification status) — same as the
ReactNativeHost override above.
Expo
Add the config plugin to app.json. There is nothing to patch by hand — the
plugin writes the configuration, and the bundle swap-in is wired up
automatically (see how Expo differs below):
{
"expo": {
"plugins": [
[
"@otaupdate/react-native",
{ "deploymentKey": "ota_live_xxxxxxxxxxxxxxxxxxxxxxxx" }
]
]
}
}One key, both platforms. Building staging and production from the same config?
Use app.config.js and read the key from an environment variable, so the build
picks it rather than an edit somebody has to remember:
export default {
expo: {
plugins: [['@otaupdate/react-native', { deploymentKey: process.env.OTA_SDK_KEY }]],
},
};serverUrl (internal deployments) and channel (only for builds still carrying
an older project-wide key) are the remaining options; iosDeploymentKey /
androidDeploymentKey still work for apps that predate channel keys.
npx expo prebuild --clean # or just let EAS Build run itThe native module does not exist in Expo Go. Use a development build (
npx expo run:ios/eas build --profile development). In Expo Go the SDK logs a warning and no-ops instead of crashing.
How Expo differs
Expo apps are bridgeless: MainApplication builds a ReactHost straight
from ExpoReactHostFactory and never exposes a ReactNativeHost, so there is
no getJSBundleFile() to override. Instead this package registers a
ReactNativeHostHandler — Expo's own extension point, the same one
expo-updates uses — which Expo asks for the bundle path at startup. Expo
autolinking discovers it; you don't wire anything up.
Two consequences worth knowing:
- The config plugin does not modify
MainApplicationon Android. If you go looking for an edit there afterprebuild, its absence is correct. - The handler returns
nullwhen developer support is on, so a debug build keeps loading from Metro and a downloaded bundle never shadows your live reload.
This package is therefore linked by both linkers, and needs to be: React
Native autolinking registers OtaUpdatePackage (the native module JS calls),
while Expo autolinking registers OtaUpdateExpoPackage (the bundle path). Expo
normally drops a package from React Native autolinking when it is also an Expo
module with its own Gradle file, which would leave NativeModules.OtaUpdate
undefined — the shipped react-native.config.js opts out of that skip. Don't
delete it.
Verified against Expo SDK 57 / React Native 0.86 with the new architecture, on both platforms.
Platform verification status
Android — verified end to end. On an Expo SDK 57 / RN 0.86 release build
(new architecture, bridgeless) running on an emulator: publish → download →
SHA-256 verify → bundle swap on restart, IMMEDIATE in-process reload, and
rollback rescuing a device already on the bad build. Confirmed by screenshot,
native log, and server-side install reports.
Bare RN Android specifically applies a mandatory/resume install by
restarting the process — verified against a real bare RN 0.87 build on an
emulator, logcat-traced end to end: publish → download → SHA-256 verify →
install → confirmed healthy, including catching and fixing a real
infinite-reload loop, a lost-update race, and a JSON null-coercion bug that
silently corrupted persisted state, along the way (see the Troubleshooting
table). An earlier attempt at a flash-free in-place swap
(OtaUpdate.createReactHost(...), shipped briefly as 1.0.5/1.0.7) was
reverted: it depended on constructing ReactHostImpl directly against an
@UnstableReactNativeAPI interface whose shape had already changed once
between RN releases, which would have meant either two permanently-diverging
SDK versions or a build that silently breaks on some RN version. The
process-restart mechanism only uses stable, decades-old Android APIs, so one
version safely covers every supported RN release instead.
iOS — verified end to end. On an Expo SDK 57 / RN 0.86 Release build
(Xcode 26, iPhone 17 Pro simulator, iOS 26.5): publish → download → SHA-256
verify → bundle swap on restart, IMMEDIATE in-process reload, and rollback
restoring the earlier bundle byte-for-byte. Confirmed by screenshot, package
hash and server-side install reports.
One library fix came out of that first real build: the podspec now sets
DEFINES_MODULE = YES. React Native and Expo build pods as static libraries,
and an Objective-C static library is not importable as a Swift module unless
CocoaPods emits a module map — without it every Expo app with a Swift
AppDelegate failed to compile with no such module 'OtaUpdate', even though
the pod linked correctly and the Objective-C itself was fine.
Use it
The one-liner — checks on app start and on every foreground, installs on next restart:
import { withOtaUpdate } from '@otaupdate/react-native';
function App() {
return <YourApp />;
}
export default withOtaUpdate(App);That is enough for most apps. withOtaUpdate also calls notifyAppReady() on
mount, which is what arms rollback protection — see below.
Mandatory releases never show the build they replace
An update check runs in JS, so the outgoing bundle has to boot before it can discover it is stale. Render the app first and the user reads the old version for a second and is then yanked into the new one — the single most common complaint about CodePush-style updates.
withOtaUpdate holds the first render while a mandatory release installs,
so the build being replaced is never drawn. A cold start goes splash → new
build.
Optional releases are untouched. The gate opens the moment the server says "not mandatory", which is before a single byte is downloaded; the update then fetches in the background and applies on the next restart exactly as it always has. Nothing blocks, and nothing about that path changed.
export default withOtaUpdate(App, {
fallback: <Splash />, // shown only during a mandatory install
mandatory: {
holdStartup: true, // default
verdictTimeoutMs: 2000, // give up waiting to *hear back* and render
maxHoldMs: 15000, // absolute ceiling, covers a slow download
},
});Both timeouts exist so a device that is offline, or a server that never
answers, boots normally instead of sitting on a splash. holdStartup: false
restores the old render-then-swap behaviour.
Already own a native splash? Leave fallback unset and keep yours up instead:
import { startupGate } from '@otaupdate/react-native';
await startupGate(); // resolves as soon as it is safe to draw
await BootSplash.hide({ fade: true });Going further: settle it before JS starts at all
The gate above stops the old build being drawn, but it still boots — you pay
React Native's cold start before the check can run. Opting into the native
pre-boot check removes even that: the check happens in getJSBundleFile() /
bundleURL(), before there is a React instance, and a mandatory release is
downloaded and handed to React Native as the bundle it starts with. The
outgoing bundle never runs, there is no reload, and the launch screen simply
stays up until the new build is ready.
| | Android (strings.xml) | iOS (Info.plist) |
|---|---|---|
| Enable | ota_blocking_startup = true | OtaBlockingStartup = <true/> |
| Budget | ota_blocking_startup_timeout_ms | OtaBlockingStartupTimeoutMs |
With the Expo plugin: { "blockingStartup": true, "blockingStartupTimeoutMs": 2500 }.
It is off by default, and the reason is the trade it makes: cold start now
waits on a network round trip. Past the budget (default 2500ms) the launch
continues on the current bundle and a late download is left queued for the next
start — the same ending as ON_NEXT_RESTART, which is a far better failure
than holding a splash indefinitely. Optional releases are ignored on this path
entirely; blocking app start on an update the user was never required to take
would be a worse bargain than the flash it removes.
Rollback protection is unchanged: a pre-boot install goes through the same
pending → promote → confirm state machine as any other, so a bundle that
crashes before notifyAppReady() is still reverted and blacklisted on the next
launch — which is also what stops a bad mandatory release from boot-looping.
Manual control
import OtaUpdate, { InstallMode, SyncStatus } from '@otaupdate/react-native';
const status = await OtaUpdate.sync({
installMode: InstallMode.ON_NEXT_RESTART,
mandatoryInstallMode: InstallMode.IMMEDIATE,
onSyncStatusChange: (s) => console.log(SyncStatus[s]),
onDownloadProgress: ({ receivedBytes, totalBytes }) =>
console.log(`${Math.round((receivedBytes / totalBytes) * 100)}%`),
// Ask before installing. Mandatory releases ignore this.
shouldInstall: (update) => confirmWithUser(update.description),
});Update UI with the hook
import { useOtaUpdate, SyncStatus } from '@otaupdate/react-native';
function UpdateBanner() {
const { available, progress, isSyncing, check, update } = useOtaUpdate();
useEffect(() => { void check(); }, []);
if (!available) return null;
return (
<View>
<Text>Version {available.label} is available</Text>
{available.description ? <Text>{available.description}</Text> : null}
{progress ? (
<Text>{Math.round((progress.receivedBytes / progress.totalBytes) * 100)}%</Text>
) : null}
<Button title="Update" onPress={() => update()} disabled={isSyncing} />
</View>
);
}Rollback protection — the part not to skip
An update that crashes on boot would be unrecoverable without this, so the SDK treats every freshly installed bundle as unproven:
install()marks the package pending.- The next launch boots into it and flags it as loading.
notifyAppReady()confirms it. From then on it is permanent.- If the app launches again while the package is still loading — i.e. the
last run never reached
notifyAppReady()— the native side reverts to the previous bundle, blacklists the bad hash, and reportsrolled_backto the server so it shows up in the dashboard.
withOtaUpdate and startAutoSync call notifyAppReady() for you. If you wire
things up yourself, you must call it, after your critical startup path:
import { notifyAppReady } from '@otaupdate/react-native';
useEffect(() => {
void notifyAppReady();
}, []);Call it too early and a crash a second later still counts as healthy; call it too late (or never) and every update rolls itself back.
Install modes
| Mode | When the new bundle loads |
|---|---|
| ON_NEXT_RESTART (default) | The next time the user cold-starts the app. Never interrupts. |
| ON_NEXT_RESUME | The next foreground, after minimumBackgroundDuration seconds in the background. |
| IMMEDIATE | Right away, with a JS reload. Reserved for mandatory fixes — it interrupts whatever the user was doing. |
API
| Export | Purpose |
|---|---|
| sync(options) | Check → download → install. Concurrent calls share one run. |
| checkForUpdate() | Just the check. Returns { update, reason }. |
| notifyAppReady() | Confirm the running bundle (see rollback above). |
| restartApp(onlyIfPending?) | Reload the JS bundle. |
| getCurrentPackage() | Running label, hash, pending/first-run flags. |
| clearUpdates() | Wipe all downloads, revert to the binary bundle. |
| startAutoSync(options) | The app-start/resume loop withOtaUpdate uses. |
| withOtaUpdate(App, options) | Root-component HOC. |
| useOtaUpdate(options) | Hook for update UI. |
| startupGate() | Resolves when it is safe to draw — i.e. no mandatory update is mid-install. |
| isStartupGateOpen() / onStartupGateChange(cb) | The same signal, synchronously and as a subscription. |
| isNativeModuleAvailable | false in Expo Go and on web. |
getConfig() returns the resolved configuration (key, server, app version,
channel, platform, running label) and OtaApiError is what server-side failures
throw.
checkForUpdate() returns a reason when there is no update:
| Reason | Meaning |
|---|---|
| up_to_date | Already on the newest applicable release. |
| not_in_rollout | A newer release exists, but this device is outside its rollout. |
| no_release_for_runtime | No release on this channel targets this native app version. |
| platform_disabled | This platform is switched off for the project. |
| unknown_channel | The key resolved, but the channel it names no longer exists. |
Publishing
From the project root, with the CLI. ota init wrote these
as npm scripts, so this is usually all anyone types:
npm run ota:staging # ota release --channel staging
npm run ota:prod # ota release --channel production
ota release --channel production --rollout 20 -m "Fix checkout crash"
ota promote --from staging --to production # same bytes, no rebuild
ota rollback --channel production # rescue devices already updated
ota status # what each release is doing
ota doctor # why isn't my update arriving?Nothing is positional: the project comes from ota.config.js, the runtime
version from your app version, and the release note and commit from git. The
runtime version is the native app version the bundle is compatible with —
devices on other binary versions never receive the release, which is the most
common cause of "I published but nothing happened".
Troubleshooting
| Symptom | Cause |
|---|---|
| The native module … is not available | Native rebuild missing (pod install / Gradle), or running in Expo Go. A Metro reload is not enough. |
| Update downloads but never applies (bare RN) | getJSBundleFile / bundleURL override missing — the app keeps loading the bundle from the binary. |
| Update downloads but never applies (Expo) | The ReactNativeHostHandler was not autolinked. Re-run npx expo prebuild --clean; check android/app/build/generated/autolinking mentions OtaUpdateExpoPackage. |
| Download fails while the check succeeds | The presigned URL points at a host the device can't reach. Set S3_PUBLIC_ENDPOINT (and PUBLIC_API_URL) to a LAN IP or public hostname, not localhost. |
| CLEARTEXT communication not permitted | Android blocks plain HTTP from API 28. Use HTTPS, or usesCleartextTraffic for local testing only. |
| Every update rolls back | notifyAppReady() is never reached — usually an early crash, or the call sits behind a screen the user has to navigate to. |
| no_release_for_runtime | The release's runtime version does not cover the installed native version. |
| Works in debug, not release | Debug builds load from Metro; the OTA path only runs in release builds. |
| The old build is visible for a second before a mandatory update swaps in | Fixed in 2.0.0. withOtaUpdate rendered the app immediately and started the check afterwards, so the outgoing build was on screen for the whole check-and-download. It now holds the first render until a mandatory release has installed (optional releases are unaffected). Apps that drive sync() by hand should await startupGate(), or do the check before rendering. For a cold start with no flash at all, also enable the native pre-boot check. |
| Android: mandatory update reload-loops (bare RN) | Fixed in 1.0.2. Before that, IMMEDIATE/ON_NEXT_RESUME tried an in-place JS reload that reused a bundle path fixed at process start, so the same mandatory update kept re-triggering forever. 1.0.2 does a real process restart instead — update to it. |
| A newer update never shows up, app stays on an older-than-expected release | Fixed in 1.0.3. notifyApplicationReady() used to clear any pending hash unconditionally — if a second update was downloaded and queued for a future restart while the first one was still being confirmed, its pending state was silently wiped and the device never advanced to it. |
| Android: old screen briefly visible during a mandatory/resume install | Improved in 1.0.3. The process-restart fix in 1.0.2 is correct but showed Android's default activity-transition animation (old screen sliding/fading out). 1.0.3 suppresses it via overridePendingTransition(0, 0) so the switch is instant. |
| Android: a normal (non-mandatory) update applies once, then randomly reverts to an older release and re-downloads | Fixed in 1.0.6 (and every version since). org.json's optString(key) returns the literal string "null" (not Kotlin null) when a saved field was JSON null — every time currentHash/pendingHash was genuinely unset and then reloaded from disk, it silently became the four-character string "null" instead of real null, which then got treated as a real (but non-existent) package and fell back to the binary bundle. This was present since the SDK's first release; iOS was never affected (its store type-checks instead of string-coercing). |
| Android: a release published with no description shows the literal text "null" | Fixed in 1.0.6 (and every version since). Same org.json optString() behavior as above, applied to the nullable description field. |
