Integrate the SDKNew

Expo Updates without EAS

Keep expo-updates in your Expo app and serve over-the-air updates from FireOTA instead of EAS Update. Bundles stay in your own Firebase Storage, with channels, rollouts and rollbacks included.

15 sections Edit this page on GitHub

How it worksNew

expo-updates is Expo's open-source update client. It asks a server for a manifest (which bundle to run) and then downloads the files. EAS Update is Expo's paid server for this. FireOTA speaks the same Expo Updates protocol, so you can swap the server by changing one URL.

Your Expo app

expo-updates checks on launch

manifest

FireOTA

picks the update for the channel, runtime and rollout

files

Your Firebase Storage

serves the bundle and assets

The app asks FireOTA which update to run. The bundle and assets download straight from your own Firebase Storage bucket.
  • On every launch, expo-updates sends its platform, runtime version, channel and the ID of the update it runs.
  • FireOTA picks the newest published update for that channel and runtime, and applies your rollout percentage.
  • The app downloads the files in the background, checks every file's SHA-256 hash, and switches to the new bundle on the next launch.
  • Installs show up in Analytics as soon as a device starts running the update.

expo-updates or the FireOTA SDK?New

Both work in Expo apps. Pick one per app, because they can't both run in the same app.

expo-updates + FireOTA (this page) vs FireOTA SDK
Native changeNone, if expo-updates is already installed. The SDK adds a config plugin.
Update UISilent: the update applies on the next launch, or you build your own UI with useUpdates(). The SDK includes ready-made prompts and screens.
Mandatory updatesNot shown to users automatically. With the SDK, a mandatory update blocks the app until it's installed.
Code signingNot yet. The SDK supports signed updates.
Best forMoving off EAS Update with no code changes. The SDK suits new apps and full control.

Choosing the SDK? Follow the Expo guide instead.

Requirements

  • Expo SDK 50 or newer with expo-updates installed, in a development or release build. Expo Go always loads from the dev server.
  • A FireOTA workspace with Firebase connected and the app registered. See Create a workspace and connect Firebase and Register your app.
  • An API key that can publish releases, for the CLI (Settings → API keys).
  • Tested on a real Android phone with Expo SDK 58 (React Native 0.88). iOS uses the same protocol.

1. Install the packages

Add expo-updates to the app (skip this if it's already there) and the FireOTA CLI as a dev dependency:

bash
npx expo install expo-updates
npm install -D @fireota/cli

Copy the App ID from the app's page in the dashboard. Every command and URL below uses it.

fireota.example.com/apps/83822cef-…

FireOTA test

runtime 1.0.0 · Android, iOS

Create release
App ID83822cef-18b1-453c-b67c-3c546a5439e8
The App ID on the app's page in the FireOTA dashboard.

2. Point expo-updates at FireOTA

In app.json (or app.config.ts), set updates.url to your app's manifest URL and choose a channel. If eas update:configure added a u.expo.dev URL, replace it.

json
{
  "expo": {
    "version": "1.4.0",
    "runtimeVersion": { "policy": "appVersion" },
    "updates": {
      "url": "https://fireota.creoai.in/api/public/v1/expo/<appId>/manifest",
      "requestHeaders": { "expo-channel-name": "production" },
      "checkAutomatically": "ON_LOAD",
      "fallbackToCacheTimeout": 0
    }
  }
}
FieldWhat it does
updates.urlWhere the app checks for updates: <FIREOTA_URL>/api/public/v1/expo/<appId>/manifest.
expo-channel-nameThe FireOTA channel this build listens to, for example production, beta or development. Use a different value per build profile.
runtimeVersionA fixed string ("1.4.0") or { "policy": "appVersion" }. Updates only go to builds with the same runtime. The fingerprint policy isn't supported by the CLI yet.
checkAutomaticallyON_LOAD checks on every cold start. Use NEVER if you only want to check from code.
fallbackToCacheTimeout0 starts the app immediately and applies the update on the next launch. A higher value (ms) waits for the download first.
Tip: Use app.config.ts to pick the channel from an environment variable, so your preview and production builds listen to different channels.

3. Make a store build

The update URL and channel are built into the native app, so make one new build and ship it (Play Store, App Store, TestFlight or an internal APK). From then on, JavaScript-only changes go out as updates.

bash
# Local release build
npx expo run:android --variant release
npx expo run:ios --configuration Release

# …or EAS Build (only the build runs on EAS; updates come from FireOTA)
eas build --platform all

Freshly installed, the app runs the bundle built into it:

Expo test app showing 'Built-in version' with embedded launch true
A new install runs the built-in bundle: isEmbeddedLaunch is true.

4. Publish an update

Set your credentials once (locally or as CI secrets):

bash
# .env or CI secrets
FIREOTA_URL=https://fireota.creoai.in
FIREOTA_API_KEY=fota_sk_live_...
FIREOTA_APP_ID=<appId>

Then publish with --expo. The CLI runs expo export, reads the runtime version from your app config, uploads the bundle and every asset to your Firebase Storage, checks the upload, and publishes to the channel:

bash
export FIREOTA_API_KEY=fota_sk_live_...
npx fireota publish --expo --app <appId> --platform android --channel production --message "Fix checkout"
fireota publish --expo
$ npx fireota publish --expo --app 83822cef-… --platform android --channel development --message "OTA update from FireOTA"
Exporting android update with expo export…
› android bundles (1):
_expo/static/js/android/index-c9501d24….hbc (1.6MB)
Update #2 (7f4cde6f-6ac2-414c-a369-0bb7f65a4912) — draft [android, local]
Uploading bundle + 0 assets…
✓ Verified in your Firebase Storage. SHA-256 7cd72657…
✓ Published update #2 (android) at 100%.
– expo-updates URL for app.json:
– https://fireota.creoai.in/api/public/v1/expo/83822cef-…/manifest
Real output from publishing an Expo update to the development channel.

More examples:

bash
# Both platforms as one update group, to 10% of the beta channel
npx fireota publish --expo --platform all --channel beta --rollout 10 --message "New onboarding"

# Force a runtime version instead of reading app.json
npx fireota publish --expo --platform ios --runtime 1.4.0 --message "Hotfix"

# Machine-readable output for CI
npx fireota publish --expo --platform android --channel production --message "CI build" --json
FlagMeaning
--expoBuild with expo export, for apps that use expo-updates.
--appApp ID. Defaults to FIREOTA_APP_ID.
--platformandroid, ios or all (one update group for both).
--channelMust match the expo-channel-name in the build.
--rolloutPercentage of devices to receive the update (default 100).
--runtimeOverrides the runtime version from app.json.
--messageRelease notes shown in the dashboard.
--jsonPrints a JSON result for CI.
Note: --file and --private-key can't be combined with --expo. If the app requires signed updates, the upload is rejected with SIGNATURE_REQUIRED.

5. Check it on a device

Open the app, close it fully, and open it again. The first launch downloads the update in the background, and the second launch runs it. These screenshots are from a real Android phone:

Before: Built-in version, embedded launch true
Before: the built-in bundle (embedded launch: true).
After: OTA update from FireOTA, embedded launch false, update id 7f4cde6f
After: the FireOTA update is running (embedded launch: false), and its update ID matches the release.

Show the same details in your own app while you test:

tsx
import * as Updates from "expo-updates";
import { Text, View } from "react-native";

export function UpdateInfo() {
  return (
    <View>
      <Text>Built-in bundle: {String(Updates.isEmbeddedLaunch)}</Text>
      <Text>Update ID: {Updates.updateId ?? "none"}</Text>
      <Text>Channel: {Updates.channel} · runtime {Updates.runtimeVersion}</Text>
    </View>
  );
}

To check and apply an update straight away (from a button, for example) instead of waiting for a restart:

ts
import * as Updates from "expo-updates";

export async function checkNow() {
  const check = await Updates.checkForUpdateAsync();
  if (!check.isAvailable) return;
  await Updates.fetchUpdateAsync();
  await Updates.reloadAsync(); // restart into the new bundle now
}

In the dashboard, the release records an install once the device starts running it.

Channels, rollouts and rollback

Expo updates use the same release controls as SDK updates. Everything on Rollouts, pause and rollback applies:

  • Staged rollout: publish with --rollout 10, then raise the percentage from the dashboard. Each device stays in the same bucket.
  • Pause: stops new downloads. Devices that already have the update keep it.
  • Roll back: devices move to the previous update on their next launch. With no earlier update, FireOTA tells them to go back to the bundle built into the app (rollBackToEmbedded).
  • Archive and delete: remove old updates from the Releases list. See Archive and delete releases.

Runtime versions

An update only goes to builds with exactly the same runtime version. Change it every time native code changes (new native package, Expo SDK upgrade, permissions or config plugins), then make a new store build.

  • "runtimeVersion": "1.4.0": you control it by hand.
  • { "policy": "appVersion" }: follows expo.version. The CLI reads it automatically.
  • Per-platform values (android.runtimeVersion / ios.runtimeVersion) take priority over the top-level value.

See Runtime versions for how FireOTA matches them.

Storage and security

  • Your bucket, your data. Bundles and assets are uploaded to the Firebase Storage bucket you connected. FireOTA stores only the release details and file paths.
  • Integrity. The manifest has a SHA-256 hash for the bundle and every asset. expo-updates rejects any file that doesn't match.
  • Upload check. The CLI sends the bundle's hash, and FireOTA checks it against the stored file before it can be published.
  • HTTPS. Use an https:// URL in production. Plain http:// only works if you allow cleartext traffic, which is meant for local testing.
  • API keys are only needed to publish. Devices call the manifest URL without a key, and the endpoint is rate limited.
  • Code signing for Expo updates is not available yet. Use the FireOTA SDK if you need signed updates.

What FireOTA sends (protocol details)

Useful for debugging. Ask for the manifest exactly as the app does:

bash
curl -i "https://fireota.creoai.in/api/public/v1/expo/<appId>/manifest" \
  -H "expo-platform: android" \
  -H "expo-runtime-version: 1.4.0" \
  -H "expo-channel-name: production" \
  -H "expo-protocol-version: 1"
http
HTTP/1.1 200 OK
content-type: multipart/mixed; boundary=…
expo-protocol-version: 1
expo-sfv-version: 0

--…
content-disposition: form-data; name="manifest"
content-type: application/json

{
  "id": "7f4cde6f-6ac2-414c-a369-0bb7f65a4912",
  "runtimeVersion": "1.4.0",
  "launchAsset": { "key": "bundle-…", "hash": "<sha256, base64url>", "url": "https://firebasestorage.googleapis.com/…" },
  "assets": [ … ],
  "extra": { "fireota": { "releaseId": "7f4cde6f-…" } }
}
--…
content-disposition: form-data; name="extensions"

{ "assetRequestHeaders": {} }
Request headerUsed for
expo-platformandroid or ios.
expo-runtime-versionOnly updates with this runtime are considered.
expo-channel-nameWhich FireOTA channel to serve.
expo-current-update-idRecords the install once the device runs a FireOTA update.
expo-embedded-update-idTells the built-in bundle apart from a downloaded update.
eas-client-idA stable device ID, used for rollout buckets and analytics.

With no update to serve, FireOTA returns a noUpdateAvailable directive, or rollBackToEmbedded when the device runs an update that has since been rolled back. Old clients on protocol 0 get a plain JSON manifest, or204 No Content when there's nothing new.

Testing against a local server

When you run FireOTA locally (npm run dev on port 3000), point a test build at it and forward the port to an Android phone with adb reverse tcp:3000 tcp:3000. Android blocks plain HTTP by default, so allow it in the test build only:

json
{
  "expo": {
    "updates": { "url": "http://localhost:3000/api/public/v1/expo/<appId>/manifest" },
    "plugins": [["expo-build-properties", { "android": { "usesCleartextTraffic": true } }]]
  }
}
Caution: Never ship a store build with cleartext traffic allowed or a localhost update URL.

Troubleshooting

  • The app still shows the old version: updates apply on the next launch. Close the app fully and open it again, or call Updates.reloadAsync().
  • noUpdateAvailable from the curl check: check that the channel, platform and runtime version match a published update, and that its rollout includes the device.
  • Works in the emulator but not on the phone: the phone can't reach the URL. Use HTTPS in production. For a local server, run adb reverse again after reconnecting the phone.
  • Download fails on some networks: some networks block Firebase Storage or break HTTPS. Try another Wi-Fi network or mobile data.
  • Firebase returns 402: billing is disabled on the Firebase project, so Storage downloads stop. Turn billing back on in Google Cloud.
  • “Could not resolve the runtime version”: set runtimeVersion to a string or the appVersion policy, or pass --runtime.
  • SIGNATURE_REQUIRED: the app requires signed updates, and Expo updates can't be signed yet. Turn that setting off for this app, or use the SDK.
  • Running in Expo Go: Expo Go always loads from your dev server. Test with a release build.

FAQ

QuestionAnswer
Do I still need an Expo account?Only if you use EAS Build. Updates no longer go through EAS.
Can I keep EAS Build?Yes. Builds can stay on EAS; only the update server changes.
What happens to users on an old build?They keep checking the old updates.url until they install the new build.
Does it cost anything?You pay Firebase for storage and downloads in your own project. There's no per-update fee from FireOTA.
Web support?No. expo-updates only updates Android and iOS apps.

Was this page helpful?