Self-Hosted OTA Updates for Bare React Native with hot-updater and Supabase
Set up over-the-air JavaScript updates in a bare React Native app with hot-updater and Supabase: init, a silent update hook, channels, deploy scripts and rollback.
This article is published in English.
Sources: Ship JavaScript Fixes in Minutes, Not Days: OTA Updates in React Native with Hot Updater
A one-line JavaScript fix in a React Native app should not have to wait days for App Store review, yet that is exactly what happens when every change ships through a new binary. Over-the-air (OTA) updates let you deliver pure JavaScript changes directly to installed apps while native changes still go through the stores. This guide walks through a production-grade setup built on the open-source hot-updater library with a self-hosted Supabase backend: installation, one-time initialization, a silent update hook with a custom prompt, per-build channels, repeatable deploy scripts and the rollback controls that make the whole thing safe.
The reference scenario is a logistics app running bare React Native on the New Architecture (version 0.85 at the time of writing), where drivers need fixes quickly and nobody wants to depend on the Expo toolchain.
What an OTA update actually replaces
Every React Native binary is really two artifacts packaged together:
- Native code: the Android and iOS shells, native modules, permissions, and everything that lives under
android/andios/. - The JavaScript bundle: screens, components, business logic, and the bulk of routine bug fixes and feature work.
A store release freezes both into an APK or IPA. The problem is that most day-to-day work only touches the JavaScript side. A mislabeled button, a wrong API parameter or a layout tweak does not require new native code, but the classic release flow still forces a full build and a review cycle.
OTA updating decouples the bundle from that cycle. Instead of treating the embedded JavaScript as permanent, the app asks an update server whether a newer bundle exists, downloads it, and swaps it in on the next reload. No store submission and no reinstall are involved; the fix reaches users on their next app launch.
One rule governs everything that follows:
JavaScript-only change: ship it over the air. Native change (a new native dependency, a version bump, a new native module): you still need a real store build.
OTA does not replace native builds. It stops native builds from blocking JavaScript releases. Before every OTA deploy, ask whether anything native changed since the last binary; if so, the new bundle may crash on devices still running the old one.
Choosing hot-updater over the alternatives
For years Microsoft CodePush was the default answer. With App Center and CodePush deprecated, teams have had to pick a replacement. The realistic options looked like this during evaluation (verify the current status of each before deciding, since this market moves quickly):
- CodePush / App Center: deprecated, so not an option.
- EAS Update: mature and reliable, but it ties a bare project more tightly to the Expo ecosystem.
- Revopush and Codemagic CodePush: capable drop-in replacements, but they are paid services.
- Firebase Storage as the backend: only available on the paid Blaze plan.
- hot-updater: an open-source, self-hostable option at no cost that supports the New Architecture.
hot-updater came out ahead for several concrete reasons:
- No licensing cost: it is open source, with no per-MAU billing.
- New Architecture support: a hard requirement for an app with the New Architecture enabled.
- Pluggable backend: storage and database are chosen through plugins. Supabase fits well because a single project provides storage, Postgres and an edge function to act as the update server, all within a generous free tier.
- Web console plus CLI: you are not confined to terminal-only workflows.
- Instant rollback: a broken bundle can be disabled with one toggle, without a redeploy or store review.
- Staged rollouts and cohorts: a bundle can go to a small share of devices first, such as 1%, then ramp up to 100%, or target a named cohort like
qa. - Active maintenance: an important signal for infrastructure your release process will depend on.
Keep one caveat in mind: the library is free, but storage and bandwidth for your bundles are your responsibility. For a small or mid-sized app, Supabase's free tier is usually enough; a large user base downloading frequent bundles is a different calculation.
Integrating hot-updater into a bare React Native app
The integration has three parts: installing packages and provisioning the backend, initializing the library once, and wiring up the update flow in your UI.
Install the packages and provision Supabase
The runtime package goes into regular dependencies, while the CLI, the bare-workflow build plugin and the Supabase plugin are development dependencies.
npm install @hot-updater/react-native
npm install -D @hot-updater/bare @hot-updater/supabase hot-updater
Next, let the CLI scaffold the backend for you.
npx hot-updater init
The init command provisions the storage bucket, sets up the required tables, and publishes the update-server edge function that the app will call. It also writes a .env file containing credentials. Add that file to .gitignore immediately, and document how to regenerate it from your Supabase service-role keys, because anyone working from a fresh clone will need to recreate it.
Initialize once at the entry point
HotUpdater.init() belongs at module scope in index.js and must execute a single time, ahead of any other use of the library. It is what tells hot-updater where to look for updates. If it is missing, every later update check has no server URL and quietly does nothing, which is one of the more confusing failure modes to debug.
The imports bring in the library, react-native-config for environment-specific values, and the app's own analytics service.
// index.js
import { HotUpdater } from '@hot-updater/react-native';
import Config from 'react-native-config';
import { analytics, AnalyticsEvent } from '@services';
The configuration itself reads the edge function URL and the update strategy from environment variables. The onError handler is deliberately minimal: it runs during bootstrap, so it should not depend on parts of the app that may not be ready yet. Its only job is to record that initialization failed.
HotUpdater.init({
baseURL: Config.HOT_UPDATER_SUPABASE_FUNCTION_URL, // your edge function URL
updateStrategy: Config.HOT_UPDATER_UPDATE_STRATEGY, // e.g. 'appVersion'
onError: (error) => {
// Bootstrap-level handler — keep it dependency-free.
// Just enough to know init itself failed.
if (__DEV__) console.log('🔥 [OTA] init error', error);
analytics.track(AnalyticsEvent.OTA_INIT_ERROR, {
message: String(error?.message ?? error),
});
},
});
Notice the separation of concerns here. init decides where updates come from. Whether the app should check at all belongs to your own logic, such as a remote feature flag. Keeping those two decisions apart makes it easy to switch OTA off for an environment without touching the bootstrap code.
A silent update hook instead of wrap()
The library ships a HotUpdater.wrap() higher-order component that handles checking, downloading and applying updates automatically. If "check on launch, update, done" is all you need, use it; it is the fastest path.
A production app often needs more control: downloading in the background, showing its own modal, throttling checks, and gating the whole mechanism behind a feature flag. For that, a custom hook that drives the API manually works better. The version below is a trimmed-down core written in TypeScript. It keeps two refs as guards, one for a check in flight and one for a bundle that is already downloaded, and exposes a modal ref, a forced-update flag and an apply function.
// useOtaUpdater.ts
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, Modal } from 'react-native';
import { HotUpdater } from '@hot-updater/react-native';
export const useOtaUpdater = () => {
const otaModalRef = useRef<Modal | null>(null);
const stagedRef = useRef(false); // a bundle is downloaded & waiting
const busyRef = useRef(false); // a check is in flight
const [otaForced, setOtaForced] = useState(false);
// 1. Check the server + stage the bundle in the background (no reload yet).
const runSilentOtaCheck = useCallback(async () => {
if (busyRef.current || stagedRef.current) return;
busyRef.current = true;
try {
const updateInfo = await HotUpdater.checkForUpdate({
updateStrategy: 'appVersion',
requestTimeout: 20000, // default 5s is too tight for a cold edge function
});
if (!updateInfo) return; // null → up to date (or check failed)
// Download & stage to disk - does NOT reload the app.
await updateInfo.updateBundle();
stagedRef.current = true;
setOtaForced(!!updateInfo.shouldForceUpdate);
otaModalRef.current?.openModal(); // ask the user to apply it
} finally {
busyRef.current = false;
}
}, []);
// 2. User tapped "Update" → swap the JS bundle and restart.
const applyOtaUpdate = useCallback(async () => {
await HotUpdater.reload(); // this is what actually applies the update
}, []);
// 3. Triggers: check on launch and whenever the app returns to foreground.
useEffect(() => {
const timer = setTimeout(runSilentOtaCheck, 3000); // backstop launch check
const sub = AppState.addEventListener('change', (s) => {
if (s === 'active') runSilentOtaCheck();
});
return () => {
clearTimeout(timer);
sub.remove();
};
}, [runSilentOtaCheck]);
return { otaModalRef, otaForced, applyOtaUpdate };
};
Several choices in this hook come from real production problems:
requestTimeout: 20000: the default timeout is 5 seconds. A cold Supabase edge function often takes longer than that, the request times out, andcheckForUpdatereturnsnull. That result is indistinguishable from "already up to date", so updates silently never arrive. Raising the timeout to 20 seconds absorbs the cold-start delay.- Stage first, apply later:
updateBundle()downloads the new bundle to disk without reloading anything.reload()is the only call that swaps it in. Splitting the two means a driver in the middle of a delivery is never interrupted by a sudden restart; the download happens in the background and the user decides when to apply it. - One guarded entry point: every trigger, whether launch, returning to the foreground or reconnecting, calls the same function, and that function exits early when
busyReforstagedRefis set. Two checks never run concurrently, and a bundle that is already staged is never downloaded again.
Two small points are worth noticing. The three-second delayed check acts as a backstop for launch, in case the first active app-state event fires before the hook subscribes. And the openModal() call assumes the ref points to a custom modal component that exposes that method, not to React Native's built-in Modal, so adapt the ref type to your own component.
Mount the hook and render the prompt
Call the hook near the root of the app and render a single modal that handles both update types. An optional update can be dismissed; a forced one hides the cancel button and cannot be closed.
// App.tsx
const { otaModalRef, otaForced, applyOtaUpdate } = useOtaUpdater();
return (
<InfoModal
modalRef={otaModalRef}
subText={otaForced ? 'A required update is available.' : 'An update is available.'}
onAcceptPress={applyOtaUpdate}
showCancelButton={!otaForced} // forced updates can't be dismissed
dismissable={!otaForced}
/>
);
Because shouldForceUpdate comes from the server, you decide per bundle whether users may postpone it. Reserve forced updates for fixes that genuinely cannot wait.
Assign each native build to a channel
The final piece tells each binary which channel it belongs to, for example development, stage or production. Because the value is compiled into the binary, the update server can decide which sequence of bundles that build receives. For Android, set a HOT_UPDATER_CHANNEL value per product flavor in the manifest; on iOS, set it per target in Info.plist. Configure it once per flavor or target and it stays put.
Two gotchas catch almost everyone. OTA only works in release builds, not debug builds. Also, each fresh native build needs one deploy to register it as a base bundle; without that, the next deploy reports
Skipped (no compatible base bundles).
Shipping an update from the command line
With everything wired up, publishing a JavaScript change takes one command.
npx hot-updater deploy -p android -t 3.0.0 -c development
The flags mean the following:
-p: the platform,androidorios.-t: the target app version the bundle is compatible with, which is your nativeversionName.-c: the channel to publish to.-f: optional; marks the update as forced so it applies immediately.
Commit your JavaScript, run the deploy, and the next time someone opens a release build on that channel, the update is waiting.
Wrapping deploys in per-environment scripts
Typing that command for two platforms across three channels quickly becomes tedious, and it is easy to publish to the wrong channel by mistake. A small Node script driven by environment variables, exposed through npm scripts, removes both problems.
The package.json entries set the channel and an optional force flag, then call the same script.
{
"scripts": {
"ota:dev": "OTA_CHANNEL=development node scripts/ota-deploy.mjs",
"ota:stage": "OTA_CHANNEL=stage node scripts/ota-deploy.mjs",
"ota:prod": "OTA_CHANNEL=production node scripts/ota-deploy.mjs",
"ota:dev:force": "OTA_CHANNEL=development OTA_FORCE=true node scripts/ota-deploy.mjs",
"ota:stage:force": "OTA_CHANNEL=stage OTA_FORCE=true node scripts/ota-deploy.mjs",
"ota:prod:force": "OTA_CHANNEL=production OTA_FORCE=true node scripts/ota-deploy.mjs"
}
}
In scripts/ota-deploy.mjs, the environment supplies both the channel and the force option; the script then extracts the target version from android/app/build.gradle unless OTA_VERSION overrides it, and deploys both platforms in sequence. execSync with stdio: 'inherit' streams the CLI output to your terminal and throws on a non-zero exit, so a failed Android deploy stops the run before iOS is attempted.
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
// Pull the native versionName straight from build.gradle so -t is never stale.
function getVersion() {
if (process.env.OTA_VERSION) return process.env.OTA_VERSION;
const gradle = readFileSync('./android/app/build.gradle', 'utf8');
const match = gradle.match(/versionName\s+["']([^"']+)["']/);
if (!match) throw new Error('Could not find versionName in build.gradle');
return match[1];
}
const version = getVersion();
const channel = process.env.OTA_CHANNEL ?? 'development';
const force = process.env.OTA_FORCE === 'true' ? '-f' : '';
for (const platform of ['android', 'ios']) {
const cmd = `npx hot-updater deploy -p ${platform} -t ${version} -c ${channel} ${force}`.trim();
execSync(cmd, { stdio: 'inherit' });
}
The day-to-day workflow then shrinks to a handful of commands.
npm run ota:dev # ship to the dev channel, both platforms
npm run ota:prod # ship to production
npm run ota:prod:force # ship to production, force immediately
Deriving the version from Gradle rather than typing it by hand removes a whole category of release mistakes: you cannot accidentally publish a bundle against a stale target version. Be aware of the assumption it makes, though. The script uses the Android versionName for both platforms, so it only works if your iOS marketing version is kept in sync. If the two can drift, read the iOS version separately or set OTA_VERSION explicitly. The inline VAR=value syntax in the npm scripts also assumes a POSIX shell; on Windows you would need a tool such as cross-env.
Rollback and staged rollouts as your safety net
Getting a fix out quickly is only half the job. The other half is limiting the blast radius when a bundle turns out to be bad, and this is where the hot-updater console earns its place.
Instant rollback
Every bundle listed in the console has an Enabled toggle. If a freshly shipped bundle is broken, switch it off. Devices that already installed it revert to the last known-good bundle, the next time they check, with no rebuild, no redeploy and no waiting on Apple or Google. The CLI offers the same escape hatch through hot-updater rollback, which reverts the most recently deployed bundle.
This is the main reason OTA is safe to rely on: undoing a mistake takes seconds. Note that rollback only reaches devices when they check again, so the check-on-foreground trigger in the hook matters for recovery just as much as for delivery.
Staged rollouts and cohorts
Rather than pushing a bundle to every device at once, you can release it to a small percentage first, watch crash reports and analytics, and then increase the share step by step until it reaches 100%. Cohorts let you target a specific group, such as an internal qa cohort, before anyone else sees the change. Combined with the Enabled toggle, this turns a risky release into a controlled experiment. Check the hot-updater documentation for the exact configuration options, as they may change between versions.
Key takeaways
- Treat OTA as a fast lane for JavaScript only; anything that touches native code still needs a store build, and each new build must be registered as a base bundle.
- Call
HotUpdater.init()exactly once inindex.js, and keep "where to check" separate from "whether to check". - Raise
requestTimeoutwhen your update server is a serverless function, because a timed-out check looks exactly like "no update available". - Download with
updateBundle()and apply withreload()separately, so users are never restarted mid-task. - Script your deploys and derive the target version from the native project to avoid wrong-channel and wrong-version mistakes.
- Plan the recovery path before the first release: know where the Enabled toggle is, and start risky changes with a small rollout.