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.
| expo-updates (EAS Update) | hot-updater | |
|---|---|---|
| Backend | Managed by Expo (EAS subscription) | Self-hosted (Supabase, Cloudflare R2, S3, custom) |
| Setup | npm install expo-updates + eas init | npx hot-updater init + pick storage/database |
| Vendor lock-in | EAS | None |
Standard install:
yarn
npm
bun
pnpm
Add the EAS project URL and runtime policy to app.json:
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:
Publish via the standard EAS flow:
Terminal
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.
Standard install + init:
yarn
npm
bun
pnpm
Wrap your app:
Publish:
yarn
npm
bun
pnpm
See hot-updater.dev/docs for full backend setup.
Only publish OTA updates for changes that don’t touch the native binary:
app.json fields that affect native output, any native iOS/Android fileFor 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.
In a pnpm/yarn workspace, Metro’s projectRoot, watchFolders, and server.unstable_serverRoot need to agree on which directory your app lives in. When they disagree, project-local assets (apps/<your-app>/assets/logo.png referenced via require('./assets/logo.png')) can silently drop from the production native bundle — the file ships in the binary but its AssetRegistry.registerAsset(...) descriptor never makes it into the Hermes bundle, so <Image source={require(...)} /> renders blank. Assets from node_modules keep registering because their paths are stable relative to whichever root Metro picks.
Two settings keep this consistent for production-like builds:
1. Pin Metro’s server root to your app directory in vite.config.ts, scoped to production-shaped builds so dev isn’t affected:
unstable_serverRoot is a Metro internal field — name may change across Metro versions. Pin your Metro version if you depend on it.
2. Set EXPO_NO_METRO_WORKSPACE_ROOT=1 in EAS production env so Expo doesn’t elevate projectRoot to the workspace root behind your back:
To verify after expo export --platform ios, the asset’s content hash should appear as a filename in dist/assets/ and as an entry in dist/metadata.json under fileMetadata.ios.assets. If a project-local asset is missing from metadata.json but the file you require() exists on disk, this is the symptom.
3. The iOS EXUpdates.bundle/app.manifest gets generated for you. vxrn’s iOS Podfile patch wraps the EXUpdates pod’s Generate updates resources build phase and runs the upstream create-updates-resources-ios.sh with EXPO_NO_METRO_WORKSPACE_ROOT=1, producing a manifest with nsBundleDir / nsBundleFilename entries for every embedded asset. The asset paths in the manifest only match what vxrn actually writes into the .app when settings 1 and 2 above are in place. If they don’t match, expo-asset’s runtime transformer falls back to uri:"" for project-local require()’d images and the logo renders blank in App Store / TestFlight builds. node_modules assets work either way.
To verify after a Release build: locate the installed .app/EXUpdates.bundle/app.manifest, confirm assets is non-empty, and check that each entry’s nsBundleDir + nsBundleFilename actually exists inside the .app. At runtime, a console.log(Image.resolveAssetSource(require('./assets/logo.png'))) should return a file://.../.expo-internal/<hash>.png URI rather than uri:"".
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
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.
If you’d rather commit babel.config.cjs / metro.config.cjs and customize them (extra babel plugins, custom Metro resolvers, etc.), generate them once:
yarn
npm
bun
pnpm
This 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:
Pass --force to overwrite existing files.
Edit this page on GitHub.