Still in Alpha and will have issues
β‘οΈ High-performance Over-The-Air (OTA) updates for React Native - Powered by Nitro Modules
Download, unzip, and apply JavaScript bundle updates at runtime without going through the App Store or Play Store review process.
- π Native Performance - Built with Nitro Modules for maximum speed
- π§΅ Off JS Thread - All operations run on different threads, keeping your JS thread free
- π Server Agnostic - Works with any CDN, S3, GitHub Releases, or custom server
- π¦ Automatic Bundle Management - Handles download, extraction, and cleanup
- π Version Control - Built-in version checking and management
- π‘οΈ Crash Safety - Auto-rollback if a new bundle crashes the app on first launch
- β©οΈ Rollback - Manual rollback to the previous bundle with one call
- π« Blacklisting - Bad versions are never re-downloaded
- π Download Progress - Track download progress with a callback
npm install react-native-nitro-ota react-native-nitro-modules
# or
yarn add react-native-nitro-ota react-native-nitro-modulesNote:
react-native-nitro-modulesis required as this library relies on Nitro Modules.
In your MainApplication.kt, add the bundle path loader:
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.margelo.nitro.nitroota.core.getStoredBundlePath
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost =
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages
override fun getJSMainModuleName(): String = "index"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
// π₯ Load OTA bundle if available, otherwise use default
override fun getJSBundleFile(): String? {
return getStoredBundlePath(this@MainApplication)
}
}
}If using modern React host:
import com.facebook.react.ReactHost
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.margelo.nitro.nitroota.core.getStoredBundlePath
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList = PackageList(this).packages,
jsBundleFilePath = getStoredBundlePath(applicationContext)
)
}
}Install pods:
cd ios && pod installUpdate AppDelegate.swift:
import UIKit
import React
import NitroOtaBundleManager
class AppDelegate: UIResponder, UIApplicationDelegate {
override func bundleURL() -> URL? {
#if DEBUG
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
// Use OTA bundle if available, otherwise fall back to the bundled file
return NitroOtaBundleManager.shared.getStoredBundleURL()
?? Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}Use the githubOTA helper to point directly to a GitHub repository:
import { githubOTA, OTAUpdateManager } from 'react-native-nitro-ota';
// Configure GitHub URLs
const { downloadUrl, versionUrl } = githubOTA({
githubUrl: 'https://github.com/your-username/your-ota-repo',
otaVersionPath: 'ota.version', // or 'ota.version.json' for advanced features
ref: 'main', // optional, defaults to 'main'
});
// Create update manager
const otaManager = new OTAUpdateManager(downloadUrl, versionUrl);
// Check for updates
const hasUpdate = await otaManager.checkForUpdates();
if (hasUpdate) {
await otaManager.downloadUpdate();
otaManager.reloadApp();
}
// Or use advanced JS checking (supports JSON format)
const updateInfo = await otaManager.checkForUpdatesJS();
if (updateInfo?.hasUpdate && updateInfo.isCompatible) {
console.log('Compatible update available:', updateInfo.remoteVersion);
await otaManager.downloadUpdate();
otaManager.reloadApp();
}import {
checkForOTAUpdates,
downloadZipFromUrl,
reloadApp,
} from 'react-native-nitro-ota';
const hasUpdate = await checkForOTAUpdates('https://your-cdn.com/ota.version');
if (hasUpdate) {
await downloadZipFromUrl('https://your-cdn.com/bundle.zip');
reloadApp();
}By default the library auto-detects the bundle file inside the zip by scanning for .bundle (Android) or .jsbundle (iOS). If your zip uses a different file name or extension, pass the relative path as the third argument:
// Bundle is at the zip root with a .js extension
await downloadZipFromUrl(
'https://your-cdn.com/bundle.zip',
undefined, // no progress callback
'index.js'
);
// Bundle is inside a subfolder
await downloadZipFromUrl(
'https://your-cdn.com/bundle.zip',
(received, total) => console.log(`${received}/${total}`),
'build/main.bundle'
);
// Using OTAUpdateManager
const otaManager = new OTAUpdateManager(downloadUrl, versionUrl);
await otaManager.downloadUpdate(undefined, 'dist/index.js');When omitted (or undefined), the existing auto-detection logic is used β no changes needed for existing setups.
Track download progress with an optional callback:
import { downloadZipFromUrl } from 'react-native-nitro-ota';
await downloadZipFromUrl(
'https://your-cdn.com/bundle.zip',
(received, total) => {
if (received === total) {
console.log(`Download complete: ${received} bytes`);
} else if (total > 0) {
const percent = Math.round((received / total) * 100);
console.log(`Downloading... ${percent}%`);
} else {
console.log(`Downloading... ${received} bytes`);
}
}
);Via OTAUpdateManager:
await otaManager.downloadUpdate((received, total) => {
setProgress(total > 0 ? received / total : -1);
});Note:
totalis-1during download when the server omitsContent-Length. The final callback always fires withreceived === total(the actual file size) to signal completion β use this instead of relying on the Promise resolve if you need the final byte count.
The library uses a "pending confirmation" pattern to protect against bad bundles:
- A new bundle is downloaded β
ota_pending_validation = trueis stored - On the next app launch, the crash handler activates only if
pending_validation == true - You call
confirmBundle()after verifying your app works β guard is disabled - If the app crashes while unconfirmed β the crash handler automatically rolls back to the previous bundle, blacklists the bad version, and the next launch uses the restored bundle
Important: Crashes in confirmed bundles are completely unaffected β the crash handler passes through to your existing crash reporter (Crashlytics, Sentry, etc.).
import {
downloadZipFromUrl,
confirmBundle,
reloadApp,
} from 'react-native-nitro-ota';
// After download, the bundle is "pending validation"
await downloadZipFromUrl(url);
reloadApp();
// On the new bundle: call confirmBundle() after verifying the app works
// (e.g. after a successful API call, a key screen loading, etc.)
confirmBundle();import { rollbackToPreviousBundle, reloadApp } from 'react-native-nitro-ota';
const success = await rollbackToPreviousBundle();
if (success) {
reloadApp(); // restarts on the previous (or original) bundle
}import { markCurrentBundleAsBad, reloadApp } from 'react-native-nitro-ota';
// Blacklists the current version and rolls back
await markCurrentBundleAsBad('payment_screen_broken');
reloadApp();Subscribe to rollback events in your app root. The callback fires:
- Immediately if a crash rollback happened during the previous session (detected from persisted history)
- In the current session when
rollbackToPreviousBundle()ormarkCurrentBundleAsBad()succeeds
import { onRollback } from 'react-native-nitro-ota';
// Register early β e.g. at the top of your App component
const unsubscribe = onRollback((record) => {
console.log('Rollback happened!');
console.log(' From version:', record.fromVersion);
console.log(' To version: ', record.toVersion);
console.log(' Reason: ', record.reason);
console.log(' Timestamp: ', new Date(record.timestamp).toISOString());
// Send to your analytics or show a user-facing notice
});
// Call unsubscribe() when the component unmountsreason values:
| Value | Meaning |
|---|---|
"crash_detected" |
Crash handler auto-rolled back the bundle |
"manual" |
rollbackToPreviousBundle() was called |
"max_rollbacks_exceeded" |
Rollback counter > 3; reset to original bundle |
| custom string | Passed to markCurrentBundleAsBad(reason) |
import { getRollbackHistory } from 'react-native-nitro-ota';
const history = await getRollbackHistory();
// [
// {
// timestamp: 1712345678000,
// fromVersion: "2",
// toVersion: "1",
// reason: "crash_detected"
// },
// ...
// ]import { getBlacklistedVersions } from 'react-native-nitro-ota';
const blacklist = await getBlacklistedVersions();
console.log('Blacklisted versions:', blacklist); // ["2", "3"]Blacklisted versions are automatically skipped by checkForOTAUpdates() β they will never be downloaded again.
| Consecutive rollbacks | Behaviour |
|---|---|
| 1β3 | Previous bundle is restored |
| > 3 | All OTA data cleared; app falls back to the original .jsbundle |
The counter resets to 0 whenever a new bundle is successfully downloaded.
All rollback features are also available on the class:
const otaManager = new OTAUpdateManager(downloadUrl, versionUrl);
// Listen for rollbacks
const unsub = otaManager.onRollback((record) => {
console.log('Rollback:', record.reason);
});
// Confirm bundle is working
otaManager.confirm();
// Manual rollback
const ok = await otaManager.rollback();
if (ok) otaManager.reloadApp();
// Mark as bad with a custom reason
await otaManager.markAsBad('checkout_screen_crash');
otaManager.reloadApp();
// Inspect history and blacklist
const history = await otaManager.getHistory();
const blacklist = await otaManager.getBlacklist();
β οΈ HIGHLY ALPHA FEATURE - This feature is experimental and needs thorough testing. Use with caution in production.
Schedule automatic background checks for updates that run periodically:
import { OTAUpdateManager } from 'react-native-nitro-ota';
const otaManager = new OTAUpdateManager(downloadUrl, versionCheckUrl);
// Schedule background check every hour (3600 seconds)
otaManager.scheduleBackgroundCheck(3600);Note: Android uses WorkManager (minimum 15-minute interval). iOS uses background tasks (behavior depends on iOS version and system conditions).
The ota.version file is a simple text file that contains your current bundle version. The version can be anything - numbers, strings, or creative identifiers like "apple", "winter2024", "bugfix-v3".
echo "1.0.0" > ota.versionFor more control, use the JSON format with semantic versioning and target app versions:
{
"version": "1.2.3",
"isSemver": true,
"targetVersions": {
"android": ["2.30.1", "2.30.2"],
"ios": ["2.30.1"]
},
"releaseNotes": "Bug fixes and improvements"
}JavaScript API for Advanced Checking:
import { checkForOTAUpdatesJS } from 'react-native-nitro-ota';
const result = await checkForOTAUpdatesJS(
'https://example.com/ota.version.json'
);
if (result?.hasUpdate && result.isCompatible) {
console.log(`New version: ${result.remoteVersion}`);
console.log(`Notes: ${result.metadata?.releaseNotes}`);
}Note: Both formats are supported. The library automatically detects which one you're using.
npx react-native bundle \
--platform android \
--dev false \
--entry-file index.js \
--bundle-output android/App-Bundles/index.android.bundle \
--assets-dest android/App-Bundlesnpx react-native bundle \
--platform ios \
--dev false \
--entry-file index.js \
--bundle-output ios/App-Bundles/index.jsbundle \
--assets-dest ios/App-Bundles# For Android
cd android && zip -r App-Bundles.zip App-Bundles
# For iOS
cd ios && zip -r App-Bundles.zip App-BundlesUpload the zipped bundle to your CDN, S3 bucket, GitHub Releases, or any file host.
In the Jellify App:
- Bundles are uploaded to a dedicated Git branch named by version and platform (e.g.,
nitro_0.19.2_android). - The upload and versioning are automated via GitHub Actions workflow.
| Function | Description |
|---|---|
checkForOTAUpdates(url) |
Returns true if a new version is available |
downloadZipFromUrl(url, onProgress?, bundleFilePath?) |
Downloads and unzips the bundle. Optional progress callback (received, total) => void. Optional bundleFilePath is the relative path to the bundle file inside the zip (e.g. "index.js", "build/main.bundle"); when omitted, auto-detection is used |
getStoredOtaVersion() |
Returns the currently active OTA version string, or null |
getStoredUnzippedPath() |
Returns the path to the active bundle file, or null |
reloadApp() |
Restarts the app to apply a downloaded bundle |
confirmBundle() |
Marks the current bundle as verified β disables crash guard |
rollbackToPreviousBundle() |
Rolls back to previous bundle; returns true on success |
markCurrentBundleAsBad(reason) |
Blacklists current bundle and triggers rollback |
getBlacklistedVersions() |
Returns string[] of blacklisted OTA versions |
getRollbackHistory() |
Returns RollbackHistoryRecord[] |
onRollback(callback) |
Subscribes to rollback events; returns an unsubscribe function |
checkForOTAUpdatesJS(url?, appVersion?) |
JS-side version check with detailed result |
hasOTAUpdate(url?, appVersion?) |
Simplified compatible-update check |
| Method | Description |
|---|---|
checkForUpdates() |
Native version check |
checkForUpdatesJS(appVersion?) |
JS-side version check |
hasCompatibleUpdate(appVersion?) |
Simple compatible-update check |
downloadUpdate(onProgress?, bundleFilePath?) |
Download with optional progress and custom bundle path |
getVersion() |
Current OTA version |
getUnzippedPath() |
Path to active bundle |
reloadApp() |
Restart the app |
confirm() |
Confirm bundle is working |
rollback() |
Roll back to previous bundle |
markAsBad(reason?) |
Blacklist + rollback with custom reason |
getBlacklist() |
List of blacklisted versions |
getHistory() |
Full rollback history |
onRollback(callback) |
Subscribe to rollback events |
scheduleBackgroundCheck(interval) |
Schedule periodic native background check |
interface RollbackHistoryRecord {
timestamp: number; // Unix ms
fromVersion: string; // OTA version that was active
toVersion: string; // Version restored ("original" = no OTA)
reason: 'crash_detected' | 'manual' | 'max_rollbacks_exceeded' | string; // custom reason from markCurrentBundleAsBad()
}See CONTRIBUTING.md for development workflow and guidelines.
MIT