With Microsoft officially shutting down App Center, many developers like me were left looking for alternatives for pushing Over-The-Air (OTA) updates in React Native. I wanted a solution that gave me full control—something lightweight, self-hosted, and easy to maintain.
So I built my own OTA update mechanism. Here's how I did it, why it works, and how you can do the same.
🧠 First, how does OTA work?
Over-the-Air updates in React Native allow you to update your JavaScript bundle without publishing a new build to the Play Store or App Store. It works like this:
- You host a JS bundle and version file on a server.
- Your app checks the server to see if a newer version is available.
- If yes, it downloads the new bundle and stores it locally.
- The app loads this new bundle on the next restart, giving users the updated experience without reinstalling the app.
💡 Why I chose to build my own system
After App Center sunset, I wanted something:
- That works offline and online.
- Doesn’t rely on third-party services.
- Gives full control over updates.
- Integrates easily into a React Native CLI app.
- Works on Android (and can be extended to iOS later).
✅ Benefits of the custom OTA system
- 🔧 Complete control over the update process.
- 💰 Zero cost – hosted the files on Firebase Hosting.
- 🔄 Instant rollback by reverting the hosted files.
- 🧪 Easy to test – can simulate updates locally.
📦 What you'll need
- Firebase Hosting (or any static hosting solution)
-
react-native-fs
for file handling -
react-native-restart
to apply updates -
axios
to fetch remote version data -
AsyncStorage
to store current version
The Basic OTA Update Flow
Before diving into implementation details, let's understand the core steps for Over-The-Air (OTA) updates in React Native:
- Bundle the app - Create a JavaScript bundle containing your latest code changes
- Upload the bundle - Send this bundle to a server where your app can access it
- Download updates - Have your app check for and download these updates automatically
My Implementation Approach
Step 1: Setting Up the Bundle Structure
I created a dedicated folder called ota-bundles
to manage all files needed for OTA updates. This includes:
- The JavaScript bundle (
index.android.bundle
) - A version file (
version.json
) - Supporting HTML files
- Asset directories for images
Here's the version.json file structure:
{
"version": "1.1.0",
"timestamp": "2025-04-22T20:34:56Z"
}
Generating the Bundle
To create the bundle, I used the following React Native CLI command:
npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output ota-bundles/index.android.bundle --assets-dest ota-bundles
What this command does:
-
--platform android
: Specifies we're bundling for Android -
--dev false
: Creates a production-optimized bundle -
--entry-file index.js
: The root file of our application -
--bundle-output
: Where to save the generated bundle -
--assets-dest
: Where to copy all static assets
This generates:
-
index.android.bundle
: The actual JavaScript code bundle -
404.html
andindex.html
: Basic HTML files needed for hosting - Various asset folders (
drawable-*
) containing all images from the app
Step 2: Hosting the Bundle with Firebase
I chose Firebase Hosting for its simplicity, reliability, and free tier. Here's how I set it up:
- Install Firebase CLI:
npm install -g firebase-tools
firebase login
- Initialize Firebase Hosting:
firebase init hosting
Configuration options I selected:
- Public directory:
./ota-bundles
(where our bundle is located) - Single-page app: No (since we're not building a web app)
- Overwrite index.html: No
- Deploy the bundle:
firebase deploy --only hosting
After successful deployment, Firebase provides a hosting URL where our bundle is accessible (e.g., https://projectname-abcd.web.app
).
The Update Manager Implementation
Here's the complete update manager code with explanations:
import AsyncStorage from "@react-native-async-storage/async-storage";
import axios from "axios";
import { Alert } from "react-native";
import RNFS from 'react-native-fs';
import RNRestart from 'react-native-restart';
// Firebase hosting URLs
const FIREBASE_BASE_URL = 'https://projectname-abcd.web.app';
const VERSION_URL = `${FIREBASE_BASE_URL}/version.json`;
const BUNDLE_URL = `${FIREBASE_BASE_URL}/index.android.bundle`;
// Local storage keys and paths
const LOCAL_VERSION_KEY = 'localJsBundleVersion';
const LOCAL_BUNDLE_PATH = `${RNFS.DocumentDirectoryPath}/index.android.bundle`;
export const checkForUpdate = async () => {
try {
// Fetch version with cache-busting to ensure fresh check
const response = await axios.get(VERSION_URL, {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Expires': '0',
},
params: {
t: Date.now(), // Timestamp to prevent caching
}
});
console.log('response', response)
const remoteVersion = response?.data?.version
const localVersion = await AsyncStorage.getItem(LOCAL_VERSION_KEY);
console.log('📦 Remote version:', remoteVersion, '| Local version:', localVersion);
if (localVersion !== remoteVersion) {
Alert.alert('Update Available', 'New update found. Download now?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Update',
onPress: async () => {
await downloadBundle(remoteVersion);
},
},
]);
}
} catch (error) {
console.log('Error fetching remote version:', error);
}
}
const downloadBundle = async (version) => {
try {
console.log('⬇️ Downloading JS bundle...');
const downloadResult = await RNFS.downloadFile({
fromUrl: BUNDLE_URL,
toFile: LOCAL_BUNDLE_PATH,
}).promise;
if (downloadResult.statusCode === 200) {
console.log('✅ Bundle downloaded to:', LOCAL_BUNDLE_PATH);
await AsyncStorage.setItem(LOCAL_VERSION_KEY, version);
Alert.alert('Update Ready', 'Restart app to apply update?', [
{
text: 'Restart Now',
onPress: () => {
RNRestart.Restart();
},
},
]);
} else {
throw new Error('Download failed with status ' + downloadResult.statusCode)
}
} catch (error) {
console.error('❌ Failed to download bundle:', error);
}
}
export const loadUpdateIfAvailable = async () => {
try {
const exist = await RNFS.exists(LOCAL_BUNDLE_PATH)
if(exist){
console.log('📲 Local bundle exists at:', LOCAL_BUNDLE_PATH);
}
} catch (error) {
console.error('❌ Failed to check local bundle:', error);
}
}
Key Packages and Their Roles
-
react-native-fs (RNFS):
- Provides filesystem access to read/write the downloaded bundle
- Needed because we can't just fetch the bundle with axios - we need to save it to the device's filesystem
-
react-native-restart (RNRestart):
- Restarts the app to apply the new bundle
- Required because React Native needs to reload to use the new code
-
@react-native-async-storage/async-storage:
- Stores the current version number locally
- Helps determine if an update is available
-
axios:
- Makes HTTP requests to check the version and download the bundle
- More robust than fetch with better error handling
Why Not Just Use Axios?
A simple axios GET request isn't sufficient because:
- We need to persist the downloaded bundle to the filesystem
- We need to verify the download was successful (status code, file integrity)
- We need to manage the local version to prevent re-downloading
- We need to handle large file downloads with progress tracking (though not shown here)
Integrating in Your App
In your main App component, you'll want to check for updates when the app starts:
useEffect(() => {
if (!loading && isAuthenticated && !showSplash) {
console.log('🔥 App ready – running update check...');
checkForUpdate(); // safe to show Alert
loadUpdateIfAvailable();
}
}, [loading, isAuthenticated, showSplash]);
This ensures:
- Updates are checked only after initial loading is complete
- The user is authenticated (if your app requires it)
- The splash screen is no longer showing (so alerts are visible)
Important Considerations
Testing and Updating Your React Native App with OTA Updates
Initial Setup and Testing
After implementing the OTA update system, here's how to properly test it on a physical device:
- Build and Install the APK:
cd android && ./gradlew assembleRelease
Then install the generated APK on your device (typically found at android/app/build/outputs/apk/release/app-release.apk
).
Making Updates and Deploying Changes
When you need to push an update to your app:
Step 1: Clean Previous Bundle
rm -rf ota-bundles/index.android.bundle
This removes the old bundle to ensure we're starting fresh with our new changes.
Step 2: Generate New Bundle
npx react-native bundle \
--platform android \
--dev false \
--entry-file index.js \
--bundle-output ota-bundles/index.android.bundle \
--assets-dest ota-bundles \
--reset-cache
Key flags explained:
-
--reset-cache
: Ensures you're not using any cached code from previous builds - The other flags maintain the same configuration as your initial bundle
Step 3: Update Version Number
Before deploying, update your ota-bundles/version.json
:
{
"version": "1.1.1",
"timestamp": "2025-04-24T10:00:00Z"
}
Always increment the version number (following semantic versioning) and update the timestamp.
Step 4: Deploy to Firebase
firebase deploy --only hosting
After successful deployment, you'll see output similar to:
✔ Deploy complete!
Project Console: https://console.firebase.google.com/project/your-project/overview
Hosting URL: https://your-project.web.app
Testing the Update Flow
- Open your installed app on the physical device
- Trigger the update check (either automatically via your useEffect or manually if you have a debug menu)
-
Verify the update process:
- The app should detect the new version (1.1.1 vs your installed 1.1.0)
- Show the update prompt
- Successfully download the new bundle
- Restart the app when prompted
Verifying the Update
To confirm the update was successful:
- Check your console logs for messages like:
📦 Remote version: 1.1.1 | Local version: 1.1.0
✅ Bundle downloaded to: /data/user/0/com.yourapp/files/index.android.bundle
After restart, verify your new changes are visible
Check AsyncStorage to confirm the new version was saved:
const version = await AsyncStorage.getItem('localJsBundleVersion');
console.log('Current version:', version); // Should show 1.1.1