Selbst gehostete OTA-Updates für reines React Native mit Hot-Updater und Supabase
Einführung von drahtlosen JavaScript-Updates in einer reinen React Native-Anwendung mit Hot-Updater und Supabase: Init, ein stummer Update-Hook, Kanäle, Bereitstellungs-Skripte und Rollback.
Quellen: Ship JavaScript Fixes in Minutes, Not Days: OTA Updates in React Native with Hot Updater
Eine einzeilige JavaScript-Änderung in einer React Native-App sollte nicht Tage lang auf eine Überprüfung im App Store warten müssen, doch genau das passiert, wenn jede Änderung über ein neues Binärdatei verteilt wird. Over-the-Air (OTA)-Updates ermöglichen es, reine JavaScript-Änderungen direkt an installierte Apps zu übertragen, während native Änderungen weiterhin über die Stores erfolgen. Diese Anleitung führt durch eine für die Produktion geeignete Einrichtung, die auf der Open-Source-Bibliothek hot-updater sowie einem selbst gehosteten Supabase-Backend basiert: Installation, einmalige Initialisierung, ein stummer Update-Hook mit benutzerdefinierter Anfrage, Kanäle pro Build, wiederverwendbare Deploy-Skripte sowie Rollback-Funktionen, die die Sicherheit gewährleisten.
Das Referenzszenario ist eine Logistik-App, die rein React Native auf der neuen Architektur (Version 0.85 zum Zeitpunkt der Erstellung) verwendet, bei der Fahrer schnell mit Fixes versorgt werden müssen und niemand auf die Expo-Toolchain angewiesen sein möchte.
Was eine OTA-Update tatsächlich ersetzt
Jedes React Native-Binärdatei setzt sich eigentlich aus zwei zusammengepackten Komponenten zusammen:
- Nativer Code: die Android- und iOS-Schalen, native Module, Berechtigungen sowie alles, was sich unter
android/undios/befindet. - Der JavaScript-Bundle: Benutzeroberflächen, Komponenten, Geschäftslogik sowie die meisten routinemäßigen Fehlerbehebungen und Funktionalitätsverbesserungen.
Eine Veröffentlichung im Store fasst beides in ein APK- oder IPA-Datei zusammen. Das Problem ist, dass die meisten alltäglichen Arbeiten nur den JavaScript-Teil betreffen. Ein falsch beschrifteter Button, ein fehlerhafter API-Parameter oder eine Anpassung des Layouts erfordern keinen neuen nativen Code, doch der herkömmliche Veröffentlichungsprozess zwingt dennoch zu einem vollständigen Build und zu einem Überprüfungszyklus.
Die OTA-Update-Funktion entkoppelt das Bundle von diesem Zyklus. Anstatt die eingebettete JavaScript-Datei als dauerhaft zu betrachten, fragt die App einen Update-Server nach einem neueren Bundle, lädt es herunter und ersetzt es beim nächsten Laden. Es ist weder ein Einreichen in einen Store noch eine Neuinstallation erforderlich; die Korrektur erreicht die Nutzer beim nächsten Start der App.
Eine Regel gilt für alles, was folgt:
Änderungen nur am JavaScript: Diese werden über das OTA-Verfahren übertragen. Änderungen auf Ebene der nativen Komponenten (neue native Abhängigkeiten, Versionsanhebung, neues natives Modul): Hier ist weiterhin eine echte Build-Version für den Store notwendig.
OTA ersetzt die nativen Builds nicht. Es verhindert lediglich, dass native Builds die Veröffentlichung von JavaScript-Änderungen blockieren. Vor jeder OTA-Veröffentlichung sollte geprüft werden, ob seit dem letzten Binärdatei-Update etwas auf Ebene der nativen Komponenten geändert wurde; falls ja, könnte das neue Bundle auf Geräten, die noch die alte Version laufen haben, abstürzen.
Hot-Updater gegenüber anderen Alternativen
Jahrelang war Microsoft CodePush die Standardlösung. Da App Center und CodePush nicht mehr unterstützt werden, mussten Teams sich nach Ersatzlösungen umsehen. Die realistischen Optionen sahen bei der Bewertung so aus (überprüfen Sie den aktuellen Status jeder Lösung vor der Entscheidung, da sich dieser Markt schnell entwickelt):
- CodePush / App Center: nicht mehr unterstützt, daher keine Option.
- EAS Update: ausgereift und zuverlässig, bindet ein reines Projekt jedoch enger an das Expo-Ökosystem.
- Revopush und Codemagic CodePush: geeignete Ersatzlösungen, doch es handelt sich um kostenpflichtige Dienste.
- Firebase Storage als Backend: nur im kostenpflichtigen Blaze-Plan verfügbar.
- hot-updater: eine kostengünstige, open-source-basierte, selbst gehostete Option, die die neue Architektur unterstützt.
hot-updater zeichnete sich aus mehreren konkreten Gründen durch:
- Keine Lizenzkosten: Es handelt sich um Open Source, ohne Abrechnung pro aktiven Benutzer.
- Unterstützung für neue Architekturen: Eine zwingende Voraussetzung für Apps, bei denen die neue Architektur aktiviert ist.
- Einfügbare Backend-Lösungen: Speicherung und Datenbank werden über Plugins ausgewählt. Supabase eignet sich gut, da ein einzelnes Projekt Speicherung, Postgres sowie eine Edge-Funktion als Update-Server bietet – alles im Rahmen eines umfangreichen kostenlosen Tarifs.
- Web-Konsole plus CLI: Sie sind nicht auf arbeitsschritt-basierte Prozesse im Terminal beschränkt.
- Sofortige Rückschaltung: Ein fehlerhafter Bundle kann mit einem einzigen Klick deaktiviert werden, ohne Neuveröffentlichung oder Überprüfung durch den Store.
- Stufenweise Einführung und Zielgruppen: Ein Bundle kann zunächst an einen kleinen Teil der Geräte verteilt werden, beispielsweise 1 %, anschließend auf 100 % erhöht werden oder eine bestimmte Zielgruppe wie
qaansprechen.
Haben Sie einen wichtigen Punkt im Hinterkopf: Die Bibliothek ist kostenlos, doch der Speicher und die Bandbreite für Ihre Pakete sind in Ihrer Verantwortung. Für eine kleine oder mittelgroße App reicht in der Regel die kostenlose Stufe von Supabase aus; bei einer großen Nutzerbasis, die häufig Pakete herunterlädt, sieht die Rechnung anders aus.
Integration des Hot-Updaters in eine reine React Native-Anwendung
Die Integration besteht aus drei Schritten: dem Installieren der Pakete und der Einrichtung des Backends, der einmaligen Initialisierung der Bibliothek sowie der Einbindung des Update-Flusses in Ihre Benutzeroberfläche.
Installieren Sie die Pakete und richten Sie Supabase ein
Das Laufzeit-Paket gehört zu den regulären Abhängigkeiten, während die CLI, das Build-Plugin für den „bare-workflow“ und das Supabase-Plugin zu den Entwicklungsabhängigkeiten zählen.
npm install @hot-updater/react-native
npm install -D @hot-updater/bare @hot-updater/supabase hot-updater
Dann lässt sich der CLI das Backend für Sie erstellen.
npx hot-updater init
Der Befehl init stellt den Speicherkorb bereit, erstellt die erforderlichen Tabellen und veröffentlicht die update-server-Edge-Funktion, die von der Anwendung aufgerufen wird. Zudem wird eine .env-Datei mit Anmeldeinformationen geschrieben. Fügen Sie diese Datei umgehend zu .gitignore hinzu und dokumentieren Sie, wie sie aus den Service-Role-Schlüsseln von Supabase neu erstellt werden kann, denn jeder, der mit einer frischen Kopie arbeitet, muss sie neu erstellen.
Einmal am Eingangspunkt initialisieren
HotUpdater.init() gehört zum Modulbereich in index.js und muss einmalig ausgeführt werden, bevor die Bibliothek sonst irgendwo verwendet wird. Sie gibt dem Hot-Updater an, wo nach Updates gesucht werden soll. Fehlt sie, verfügt jede spätere Überprüfung auf Updates nicht über eine Server-URL und handelt stillschweigend nicht – was zu den verwirrendsten Fehlermodi gehört, die man debuggen muss.
Durch die Imports werden die Bibliothek sowie react-native-config für umgebungsbezogene Werte und der eigene Analysedienst der App eingebunden.
// index.js
import { HotUpdater } from '@hot-updater/react-native';
import Config from 'react-native-config';
import { analytics, AnalyticsEvent } from '@services';
Die Konfiguration selbst liest die URL der Edge-Funktion sowie die Aktualisierungsstrategie aus Umgebungsvariablen. Der onError-Handler ist absichtlich minimal gehalten: Er wird während des Bootstraps ausgeführt, daher sollte er nicht von Teilen der App abhängen, die möglicherweise noch nicht bereit sind. Seine einzige Aufgabe besteht darin, festzuhalten, dass die Initialisierung fehlgeschlagen ist.
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),
});
},
});
Achten Sie auf die Trennung der Verantwortlichkeiten hier. init entscheidet, woher die Updates stammen. Ob die App überhaupt prüfen soll, gehört zu Ihrer eigenen Logik – beispielsweise durch ein Remote-Feature-Flag. Durch die Trennung dieser beiden Entscheidungen lässt sich OTA in einem bestimmten Umfeld einfach deaktivieren, ohne den Bootstrap-Code anzufassen.
Ein stummer Update-Hook anstelle von wrap()
Die Bibliothek bietet ein HotUpdater.wrap()-Komponente an, die das Automatische Prüfen, Herunterladen und Anwenden von Updates übernimmt. Wenn Sie lediglich „bei Start prüfen, aktualisieren und fertig“ benötigen, verwenden Sie diese Funktion – sie ist der schnellste Weg.
Eine Produktionsanwendung benötigt oft mehr Kontrolle: Herunterladen im Hintergrund, Anzeige eines eigenen Modals, Drosselung von Überprüfungen sowie Abschirmung des gesamten Mechanismus hinter einer Feature-Flag. Dafür eignet sich ein benutzerdefinierter Hook, der die API manuell steuert. Die untenstehende Version ist ein reduzierter Kern, der in TypeScript geschrieben wurde. Er verwendet zwei Refs als Schutzmechanismen – einen für eine laufende Überprüfung und einen für bereits heruntergeladene Daten – und stellt außerdem einen Modal-Ref, eine Flagge für erzwungene Aktualisierungen sowie eine Anwendefunktion bereit.
// 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 };
};
Mehrere Entscheidungen in diesem Hook stammen aus tatsächlichen Produktionsproblemen:
requestTimeout: 20000: Die Standard-Wartezeit beträgt 5 Sekunden. Eine neu gestartete Supabase Edge-Funktion benötigt oft länger Zeit, wodurch die Anfrage abbricht undcheckForUpdatenullzurückgibt. Dieses Ergebnis ist nicht von „bereits auf dem neuesten Stand“ zu unterscheiden, weshalb Updates stumm nie ankommen. Eine Erhöhung der Wartezeit auf 20 Sekunden kompensiert die Verzögerung durch den Neustart.- Zuerst in Stufe bringen, später anwenden:
updateBundle()lädt das neue Bundle ohne Neu laden irgendetwas auf die Festplatte.reload()ist die einzige Funktion, die es tatsächlich einsetzt. Durch diese Trennung wird verhindert, dass ein Driver mitten in einer Lieferung durch einen plötzlichen Neustart unterbrochen wird; das Herunterladen erfolgt im Hintergrund und der Benutzer entscheidet, wann es angewendet wird.
busyRef oder stagedRef gesetzt wird. Zwei Überprüfungen laufen niemals gleichzeitig, und ein bereits vorbereiteter Bundle wird nie erneut heruntergeladen.Zwei Punkte verdienen besondere Beachtung. Die dreisekündige verzögerte Überprüfung dient als Sicherheitsnetz beim Start, falls das erste active-Event des App-Zustands vor der Anmeldung an den Hook ausgelöst wird. Zudem geht die Aufrufung von openModal() davon aus, dass der Ref auf ein benutzerdefiniertes Modal-Komponente verweist, das diese Methode bereitstellt – und nicht auf React Natives eingebautes Modal; passen Sie daher den Ref-Typ an Ihre eigene Komponente an.
Der Hook wird montiert und die Anfrage angezeigt
Rufen Sie den Hook in der Nähe der Wurzel der Anwendung auf und rendern Sie ein einziges Modal, das beide Update-Typen handhabt. Ein optionales Update kann abgelehnt werden; ein erzwungenes versteckt die Abbrechungstaste und lässt sich nicht schließen.
// 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}
/>
);
Da shouldForceUpdate vom Server stammt, entscheiden Sie pro Bundle, ob Benutzer es verschieben dürfen. Reservieren Sie erzwungene Updates für Fehlerbehebungen, die tatsächlich nicht warten können.
Weisen Sie jede native Build-Version einem Channel zu
Der letzte Bestandteil gibt jedem Binary mit, zu welchem Kanal es gehört – beispielsweise development, stage oder production. Da der Wert in das Binary kompiliert wird, kann der Update-Server entscheiden, welche Sequenz an Bündeln von diesem Build empfangen wird. Für Android sollte man für jede Produktvariante einen Wert für HOT_UPDATER_CHANNEL im Manifest festlegen; auf iOS erfolgt die Einstellung pro Ziel in Info.plist. Konfigurieren Sie dies einmal pro Variante oder Ziel – danach bleibt es unverändert.
Zwei Probleme treten bei fast allen auf: OTA funktioniert nur in Release-Builds, nicht in Debug-Builds. Zudem benötigt jedes neue native Build eine Bereitstellung, um als Basisbündel registriert zu werden; ohne diese meldet die nächste Bereitstellung
Skipped (no compatible base bundles).
Ein Update über die Kommandozeile versenden
Sobald alles verbunden ist, erfordert die Veröffentlichung einer JavaScript-Änderung nur einen Befehl.
npx hot-updater deploy -p android -t 3.0.0 -c development
Die Flags bedeuten Folgendes:
-p: die Plattform,androidoderios.-t: die Ziel-App-Version, mit der das Bundle kompatibel ist – das ist Ihre nativeversionName.-c: der Channel, auf den veröffentlicht werden soll.-f: optional; markiert die Aktualisierung als erzwungen, sodass sie sofort angewendet wird.
Commitpen Sie Ihr JavaScript, führen Sie den Deploy aus – beim nächsten Öffnen einer Release-Build-Version auf diesem Channel ist die Aktualisierung bereits bereit.
Deploys in umgebungsspezifischen Skripten einbetten
Das Eingeben dieses Befehls für zwei Plattformen über drei Kanäle wird schnell mühsam, und es ist leicht, versehentlich auf den falschen Kanal zu veröffentlichen. Ein kleines Node-Skript, das durch Umgebungsvariablen gesteuert wird und über npm Scripts zugänglich ist, beseitigt beide Probleme.
Die Einträge in package.json legen den Kanal sowie ein optionales Force-Flag fest und rufen anschließend dasselbe Skript auf.
{
"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 liefert die Umgebung sowohl den Kanal als auch die Force-Option; das Skript extrahiert anschließend die Zielversion aus android/app/build.gradle, sofern OTA_VERSION diese nicht überschreibt, und deployt beide Plattformen nacheinander. execSync mit stdio: 'inherit' leitet die CLI-Ausgabe an Ihr Terminal weiter und wirft bei einem nicht-nullen Ausgangscode einen Fehler aus, sodass ein fehlgeschlagener Android-Deploy den Ablauf stoppt, bevor versucht wird, iOS zu deployen.
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' });
}
Der tägliche Arbeitsablauf reduziert sich dann auf nur wenige Befehle.
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
Durch das Ableiten der Version aus Gradle anstelle des manuellen Eingabens werden ganze Kategorien von Veröffentlichungsfehlern beseitigt: Man kann versehentlich kein Bundle gegen eine veraltete Zielversion veröffentlichen. Beachten Sie jedoch die zugrunde liegende Annahme. Das Skript verwendet für beide Plattformen den Android versionName, sodass es nur funktioniert, wenn Ihre iOS-Marketing-Version synchron gehalten wird. Falls sich diese beiden Versionen voneinander entfernen, lesen Sie die iOS-Version separat oder setzen Sie OTA_VERSION explizit. Die inline Syntax VAR=value in den npm-Skripten setzt außerdem voraus, dass es sich um eine POSIX-Shell handelt; unter Windows wäre ein Tool wie cross-env erforderlich.
Rollback und schrittweise Rollouts als Sicherheitsnetz
Schnell eine Lösung zu finden, ist nur die halbe Miete. Die andere Hälfte besteht darin, den Ausbreitungsbereich einzuschränken, falls sich ein Paket als fehlerhaft erweist – und genau hier kommt die Hot-Updater-Konsole ins Spiel.
Sofortige Rücksetzung
Jedes in der Konsole aufgelistete Paket verfügt über einen Schalter mit dem Label Enabled. Falls ein frisch bereitgestelltes Paket fehlerhaft ist, schalten Sie diesen Schalter aus. Geräte, die das Paket bereits installiert haben, wechseln beim nächsten Abfragen auf das letzte funktionierende Paket – ohne Neuinstallation, erneute Bereitstellung und ohne Warten auf Apple oder Google. Die CLI bietet denselben Ausweg über hot-updater rollback, mit dem das kürzlich bereitgestellte Paket zurückgesetzt wird.
Dies ist der Hauptgrund, warum man sich auf OTA verlassen kann: Ein Fehler kann in Sekundenschnelle rückgängig gemacht werden. Beachten Sie, dass die Rücksetzung nur dann bei den Geräten ankommt, wenn sie erneut abfragen – daher ist der Trigger für die Abfrage im Hintergrund genauso wichtig für die Wiederherstellung wie für die Lieferung.
Stufenweise Einführung und Kohorten
Anstatt einen Paketinhalt auf einmal auf alle Geräte zu senden, können Sie ihn zunächst einem kleinen Prozentsatz zur Verfügung stellen, Crash-Reports sowie Analysedaten prüfen und anschließend schrittweise den Anteil erhöhen, bis er 100 % erreicht hat. Kohorten ermöglichen es Ihnen, eine bestimmte Gruppe – wie beispielsweise eine interne qa-Kohorte – gezielt anzusprechen, bevor andere die Änderung sehen. In Kombination mit dem Schalter „Eingeschaltet“ verwandelt dies eine riskante Veröffentlichung in ein kontrolliertes Experiment. Prüfen Sie die Dokumentation zu Hot-Updater für die genauen Konfigurationsmöglichkeiten, da diese zwischen den Versionen variieren können.
Hauptpunkte
- Betrachten Sie OTA lediglich als beschleunigte Methode für JavaScript; alles, was native Code betrifft, benötigt weiterhin eine Store-Build-Version, und jede neue Build muss als Basispaket registriert werden.
HotUpdater.init() genau einmal in index.js auf und trennen Sie die Angabe „wo nachzusehen“ von der Entscheidung „ob nachzusehen ist“.requestTimeout aus, wenn Ihr Update-Server eine serverlose Funktion ist, denn ein abgelaufener Check sieht genauso aus wie „keine Updates verfügbar“.updateBundle() herunter und wenden Sie das Update mit reload() getrennt voneinander an, damit Benutzer während einer Aufgabe nicht neu gestartet werden.