Hot Updater: Ship React Native Fixes Without the App Store Waiting Room
If you ship React Native apps, you already know the pain of the store review queue. A one-line copy fix, a feature flag flip, or a tiny UI tweak technically lives in your JavaScript bundle, not your native binary, yet it still has to crawl through Apple and Google review before users see it. Over-the-air (OTA) update tools exist to dodge that queue for JS-only changes, and for years Microsoft CodePush was the go-to. Then App Center was retired on March 31, 2025, stranding a huge number of apps overnight.
hot-updater is the answer for teams orphaned by that shutdown who also do not want to hand the keys to a hosted, per-user-billed service. It is a self-hostable OTA update system for React Native: you bring your own cloud (Supabase, AWS, Cloudflare, or Firebase), you own the storage bucket and metadata database, and you run a web console you control. No per-MAU billing, no vendor lock-in. It works with bare React Native CLI apps and Expo apps, and it supports the New Architecture (Fabric and TurboModules) plus Hermes.
What Makes It Worth a Look
- Own your infrastructure. Updates flow through cloud you already pay for, so your only cost is infra, never a per-active-user tax.
- Multi-provider plugin system. Mix and match build, storage, and database layers across Supabase, AWS S3 with Lambda@Edge, Cloudflare R2 with D1, or Firebase.
- Automatic crash detection and rollback. If a freshly applied bundle crashes on boot, Hot Updater rolls back to the last known-good bundle on its own.
- Native-aware fingerprinting. The
fingerprintstrategy refuses to push a JS bundle to a binary whose native layer has changed, defusing the classic OTA footgun. - Staged rollouts and channels. Ship to 25% of users, or target a
stagingcohort, with one flag. - A self-hosted web console. Browse deployed bundles, promote or roll back, and tune rollout percentages from a UI you run yourself.
How the Pieces Fit Together
Hot Updater is built on a three-layer plugin architecture, and understanding the split is the key to the whole tool. Each layer is independent, so you can swap one without touching the others.
- Build plugins generate the bundle. Metro, Re.Pack, and Expo are all supported.
- Storage plugins decide where the bundle ZIP lives: AWS S3, Cloudflare R2, Supabase Storage, or Firebase Storage.
- Database plugins hold the update metadata: bundle IDs, versions, channels, rollout percentages, and active or rollback flags.
At runtime the flow is straightforward. On launch, the app calls a check-update endpoint. The server compares the device's current bundle, version, fingerprint, and channel against the latest active bundle for that target, then reports whether an update exists and whether it is forced. If so, the client downloads the ZIP from storage, stores it, and applies it on the next reload (or immediately for force updates).
Getting It Into Your Project
Install the CLI and the core packages, then add the provider plugin that matches your cloud.
# npm
npm install hot-updater @hot-updater/react-native @hot-updater/bare
npm install @hot-updater/supabase
# yarn
yarn add hot-updater @hot-updater/react-native @hot-updater/bare
yarn add @hot-updater/supabase
The fastest way to wire everything up is the interactive init wizard, which scaffolds your config file, env files, and the server function for your chosen provider:
npx hot-updater init
It asks two questions, your build system (bare, Expo, or Re.Pack) and your provider (Supabase, AWS, Cloudflare, or Firebase), and writes the boilerplate for you.
Wiring Up the First Deploy
Describing Your Infrastructure
Everything Hot Updater needs to know lives in hot-updater.config.ts. Here is a bare React Native app backed by Supabase. Notice how the three plugin layers map directly onto the architecture above.
import { bare } from "@hot-updater/bare";
import { supabaseStorage, supabaseDatabase } from "@hot-updater/supabase";
import { defineConfig } from "hot-updater";
import { config } from "dotenv";
config({ path: ".env.hotupdater" });
export default defineConfig({
build: bare({ enableHermes: true }),
storage: supabaseStorage({
supabaseUrl: process.env.HOT_UPDATER_SUPABASE_URL!,
supabaseAnonKey: process.env.HOT_UPDATER_SUPABASE_ANON_KEY!,
bucketName: process.env.HOT_UPDATER_SUPABASE_BUCKET_NAME!,
}),
database: supabaseDatabase({
supabaseUrl: process.env.HOT_UPDATER_SUPABASE_URL!,
supabaseAnonKey: process.env.HOT_UPDATER_SUPABASE_ANON_KEY!,
}),
});
The beauty of the plugin split shows up the moment you want to move clouds. Switching to Cloudflare swaps supabaseStorage and supabaseDatabase for r2Storage and d1Database; switching to AWS swaps in s3Storage and s3Database behind Lambda@Edge. The build line stays exactly the same in every case, because your bundle does not care where it ends up living.
Wrapping the App Entry Point
The client side is a single higher-order component around your root app. This step is not optional, and the reason matters.
import { HotUpdater } from "@hot-updater/react-native";
import { View, Text } from "react-native";
function App() {
return (
<View>
<Text>Hello World</Text>
</View>
);
}
export default HotUpdater.wrap({
baseURL: "https://your-update-server-url/api/check-update",
updateStrategy: "appVersion",
fallbackComponent: ({ progress, status }) => (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>{status === "UPDATING" ? "Updating..." : "Checking for Update..."}</Text>
</View>
),
})(App);
HotUpdater.wrap runs the update check at the entry point, but more importantly it is what enables automatic crash detection and rollback. Skip it and you lose the safety net, not just convenience: a bad bundle that crashes on boot has no way to revert itself. The fallbackComponent is what users see while a check or download is in progress, and it receives progress, status, and message props so you can render whatever loading experience you like.
Pushing an Update
With config and wrapper in place, shipping is a one-liner.
npx hot-updater deploy # iOS then Android
npx hot-updater deploy -p ios # one platform only
npx hot-updater deploy -i -f # interactive, force update
npx hot-updater console # open the web console
The console gives you a self-hosted UI for browsing bundles, promoting or activating one, doing rollbacks, and managing rollout percentage and channels, all without touching the CLI.
Going Deeper
Choosing an Update Strategy
The updateStrategy option is the single most important decision you will make, because it determines how Hot Updater decides whether a bundle is safe to deliver.
export default HotUpdater.wrap({
baseURL: "https://your-update-server-url/api/check-update",
updateStrategy: "fingerprint",
})(App);
There are two choices. appVersion scopes an update to a specific native app version, say 1.4.0. It is the simpler mental model, but the discipline of keeping the JS bundle compatible with that native version is entirely on you. fingerprint uses @expo/fingerprint to hash the native layer; if your native code changed, the fingerprint differs, and Hot Updater simply will not push a JS bundle built against a different native runtime. That prevents the most common OTA disaster, where a JS update assumes a native module the installed binary does not have. The fingerprint strategy needs @expo/fingerprint installed as a peer dependency, so do not forget it.
Staged Rollouts and Channels
You rarely want to ship to everyone at once. Hot Updater combines two concepts to give you canary releases. Channels like production, staging, and dev let you target cohorts, and the channel is baked into every check-update URL. Rollout percentages let you limit who receives a bundle even within a channel.
# Roll out to 25% of users on iOS
npx hot-updater deploy -p ios -r 25
# Force update, interactive
npx hot-updater deploy -i -f
Start at a small percentage, watch your crash dashboards, and promote the bundle to wider rollout from the console once you trust it.
Customizing the Update Lifecycle
The wrap options give you fine-grained hooks into the whole update process, which is handy for auth-protected endpoints, custom progress UI, and manual reload control.
export default HotUpdater.wrap({
baseURL: "https://your-update-server-url/api/check-update",
updateStrategy: "fingerprint",
reloadOnForceUpdate: false,
requestHeaders: {
Authorization: "Bearer your-token-here",
},
requestTimeout: 5000,
onProgress: (progress) => {
console.log(`Download progress: ${Math.round(progress * 100)}%`);
},
onUpdateProcessCompleted: ({ status, shouldForceUpdate }) => {
if (status === "UPDATE" && shouldForceUpdate) {
HotUpdater.reload();
}
},
onError: (error) => {
console.error("Update failed", error);
},
})(App);
A few details worth internalizing. requestHeaders lets you protect your check-update endpoint with a bearer token or similar. onProgress reports download progress as a decimal between 0 and 1. By setting reloadOnForceUpdate to false, you take manual control of restarts and call HotUpdater.reload() yourself, typically inside onUpdateProcessCompleted once you have confirmed the user is at a safe moment to restart. The completion callback reports a status of ROLLBACK, UPDATE, or UP_TO_DATE, so you can react accordingly. And if your network stack is unusual, the resolver option lets you replace baseURL entirely with fully custom logic for GraphQL or proprietary APIs.
A Few Honest Caveats
Hot Updater is powerful, but OTA updates carry rules that no library can wave away. You only ship JavaScript, never native code, so a bundle that depends on native module changes the installed binary lacks will crash; the fingerprint strategy exists precisely to guard against this. App Store and Play Store policy still applies too: OTA is fine for bug fixes and minor changes, but it must not materially change your app's purpose or bypass review. Self-hosting also means you own the failure modes, including storage costs, endpoint availability, auth, and CDN configuration. Finally, the project is pre-1.0 and moving fast, with the latest release at 0.32.0 and roughly weekly cadence, so pin your versions and expect some API churn.
The Takeaway
Hot Updater fills a very real gap. For teams burned by the CodePush shutdown who want a self-hosted replacement that is actively maintained, or for anyone who would rather not adopt Expo EAS Update's hosted per-user pricing, it offers a modern, multi-cloud, plugin-based design with the safety features that matter most: automatic crash rollback, native-aware fingerprinting, staged rollouts, and a console you host yourself. If you already run on Supabase, Cloudflare, AWS, or Firebase, you can have OTA on infrastructure you fully own. That is a compelling trade: a little setup effort in exchange for shipping fixes in minutes instead of days, on your terms.