Over-the-air (OTA) updates ship JavaScript and asset changes to installed native apps without going through TestFlight or the Play Store.
One ships its own OTA client. Set the update url and runtime version in native.app:
one({ native: { app: { // ... updates: { url: 'https://updates.example.com', runtimeVersion: '1.0', }, }, },})Prebuild stamps both into the binary. Publish from the app’s source and serve the output directory at <url>/<platform>/<runtimeVersion>/:
one updates publish --platform ios --out ./ota-outone updates publish --platform android --out ./ota-out-androidThe launcher picks the newest downloaded update for the binary’s runtime version before React Native starts, and rolls back when an update fails before first render. See Updates for the client API and publish options.
hot-updater is a self-hosted alternative (Supabase, Cloudflare R2, S3, custom). Install and init:
bun add @hot-updater/react-native react-native-mmkvbun add -D hot-updater @hot-updater/bare @hot-updater/supabasenpx hot-updater initWrap your app:
import { HotUpdater } from '@hot-updater/react-native'
export default HotUpdater.wrap({ source: process.env.ONE_PUBLIC_HOT_UPDATER_URL,})(App)Publish:
npx hot-updater deploy -p ios -c productionnpx hot-updater deploy -p android -c productionSee hot-updater.dev/docs for full backend setup. Apps on EAS Update follow Coming from Expo.
Only publish OTA updates for changes that don’t touch the native binary:
native.app fields that affect native output, any native iOS/Android fileFor those, bump runtimeVersion, build a fresh TestFlight/Play binary, and only then publish OTA updates against the new runtime. The runtime version gates delivery so devices on the old binary don’t receive incompatible updates.
If you’d rather commit babel.config.cjs / metro.config.cjs and customize them (custom Metro resolvers, watcher options, etc.), generate them once:
babel.config.cjs has no effect on the bundle. To replace a Babel plugin, use native.bundlerOptions.nativeTransformModules — see Metro mode.npx one metro-ejectThis writes shim files (delegating to one/babel-preset and one/metro-config) without the @one/generated marker comment. After ejecting:
A typical ejected metro.config.cjs looks like:
const { withOne } = require('one/metro-config')const oneBundlerOptions = { routerRoot: 'app',}
module.exports = (async () => { const config = await withOne(__dirname, oneBundlerOptions) // your customizations here return config})()Pass --force to overwrite existing files.
Edit this page on GitHub.