Over-the-air Updates

Over-the-air (OTA) updates ship JavaScript and asset changes to installed native apps without going through TestFlight or the Play Store. One works with two OTA libraries — pick the one that matches your hosting story. The framework handles the integration automatically; your repo follows the standard install flow.

Choosing

expo-updates (EAS Update)hot-updater
BackendManaged by Expo (EAS subscription)Self-hosted (Supabase, Cloudflare R2, S3, custom)
Setupnpm install expo-updates + eas initnpx hot-updater init + pick storage/database
Vendor lock-inEASNone

expo-updates

Standard install:

npm install expo-updates

Add the EAS project URL and runtime policy to app.json:

{
"expo": {
"updates": { "url": "https://u.expo.dev/<your-eas-project-id>" },
"runtimeVersion": { "policy": "appVersion" }
}
}

appVersion is the supported policy. fingerprint isn’t fully wired up yet.

Set the main field in package.json so expo export (which eas update invokes) finds One’s router entry:

{ "main": "one/metro-entry" }

Publish via the standard EAS flow:

Terminal

eas update --channel staging
eas update --channel production

That’s the entire user surface — no babel.config / metro.config in your repo. One starters include "postinstall": "one patch || true" so EAS workers generate the Metro/Babel shims with your real one() router/setup options during install.

hot-updater

Standard install + init:

npm install @hot-updater/react-native react-native-mmkv bun add -D hot-updater @hot-updater/bare @hot-updater/supabase npx hot-updater init

Wrap your app:

import { HotUpdater } from '@hot-updater/react-native'
export default HotUpdater.wrap({
source: process.env.EXPO_PUBLIC_HOT_UPDATER_URL,
})(App)

Publish:

npx hot-updater deploy -p ios -c production npx hot-updater deploy -p android -c production

See hot-updater.dev/docs for full backend setup.

What’s OTA-safe

Only publish OTA updates for changes that don’t touch the native binary:

  • ✅ JavaScript, TypeScript, JSX, styles, copy, bundled assets
  • ❌ Expo SDK upgrades, native dependencies, config plugins, app.json fields that affect native output, any native iOS/Android file

For those, bump your app version, build a fresh TestFlight/Play binary, and only then publish OTA updates against the new runtime. The appVersion strategy (supported by both libraries) gates delivery so devices on the old version don’t receive incompatible updates.

Publishing locally (expo-updates only)

eas update from your own machine works the same way EAS workers run it — but One’s auto-setup only fires when CI=1 or EAS_BUILD=true is set, so your local working tree stays clean. After dependencies are installed, generate the same shims explicitly before publishing:

Terminal

CI=1 npx one patch
eas update --channel staging

The first command sets up the bundler-config files Metro needs. They’re regenerated whenever one patch runs in CI/EAS — gitignore them or leave them out of source control entirely. Most projects publish from EAS Workflows or CI instead and never need this.

Owning the config files yourself

If you’d rather commit babel.config.cjs / metro.config.cjs and customize them (extra babel plugins, custom Metro resolvers, etc.), generate them once:

npx one metro-eject

This writes shim files (delegating to one/babel-preset and one/metro-config) without the @one/generated marker comment. After ejecting:

  • The files are yours — edit freely and commit them.
  • The CI postinstall hook detects the missing marker and won’t touch them.
  • You stay in charge of keeping them in sync with future One releases.

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.

Reference

Edit this page on GitHub.