Bare React Native

Otapush works with bare React Native — projects without app.json and the managed workflow — just as it does with Expo. The expo-updates native module runs fine in bare RN; it needs the expo package (expo-modules core) plus a native configuration that otapush init writes for you. The server side is identical: a bare app speaks the same Expo Updates Protocol v1, described in the protocol reference.

What does a bare RN project need before Otapush works?

Two npm packages — expo (expo-modules core) and expo-updates — and the native wiring for them:

npx install-expo-modules@latest
npm install expo-updates    # or: yarn add / pnpm add / bun add
cd ios && pod install

install-expo-modules adds the expo package and wires expo-modules autolinking into android/ and ios/. If either dependency is missing, otapush init prints the exact install commands — matching your package manager by lockfile (bun.lock/bun.lockb, yarn.lock, pnpm-lock.yaml, otherwise npm) — and continues anyway: the native files get patched, but the app will not build until the packages are installed.

How does otapush init know my project is bare?

It looks at the project root: an app.json with an expo field means the managed flow; no app.json but a react-native dependency in package.json means bare. For a bare project, init:

  • saves the code-signing certificate to ./certs/certificate.pem (public material — commit it),
  • patches android/app/src/main/AndroidManifest.xml,
  • creates or updates ios/<ProjectName>/Expo.plist — or ios/<ProjectName>/Supporting/Expo.plist if that already exists; the project name is taken from the ios/*.xcodeproj directory,
  • writes otapush.config.json with projectType: "bare".

Patching is idempotent: re-running init replaces the values of existing keys instead of duplicating them.

What exactly gets patched?

Android: android/app/src/main/AndroidManifest.xml

Meta-data entries are inserted inside <application> (the key names come from UpdatesConfiguration.kt in the expo-updates sources):

<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://your-server/api/updates/<slug>/manifest"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="1.0.0"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.CODE_SIGNING_CERTIFICATE" android:value="-----BEGIN CERTIFICATE----- ..."/>
<meta-data android:name="expo.modules.updates.CODE_SIGNING_METADATA" android:value="{&quot;alg&quot;:&quot;rsa-v1_5-sha256&quot;,&quot;keyid&quot;:&quot;main&quot;}"/>

An existing meta-data with the same android:name is replaced whole. Note that EXPO_UPDATE_URL points at the manifest endpoint without a channel — the server serves prod unless the URL carries ?channel=<name>.

iOS: ios/<ProjectName>/Expo.plist

Top-level keys (names from UpdatesConfig.swift):

<key>EXUpdatesEnabled</key><true/>
<key>EXUpdatesURL</key><string>https://your-server/api/updates/<slug>/manifest</string>
<key>EXUpdatesRuntimeVersion</key><string>1.0.0</string>
<key>EXUpdatesCheckOnLaunch</key><string>ALWAYS</string>
<key>EXUpdatesCodeSigningCertificate</key><string>-----BEGIN CERTIFICATE----- ...</string>
<key>EXUpdatesCodeSigningMetadata</key>
<dict>
  <key>alg</key><string>rsa-v1_5-sha256</string>
  <key>keyid</key><string>main</string>
</dict>

When the file exists, the six managed keys are removed and re-inserted at the end of the dictionary; when it does not, a minimal plist is created around them.

Expo.plist must be part of the Xcode target — expo-updates reads it from the main bundle. If init created the file and it is new to your project, add it to the target in Xcode.

What if a file is missing?

These are the errors init produces in a bare project, verbatim, with what to do:

  • "AndroidManifest.xml not found at android/app/src/main/AndroidManifest.xml" — you are not in the project root, or the project uses a non-standard layout. Run init from the root; for custom paths (some monorepos), patch the manifest by hand following the sample above.
  • "No tag found in …" — the manifest is malformed or highly customized; add the meta-data entries manually inside <application>.
  • "No ios/ directory found" or **"No .xcodeproj found under ios/"* — init cannot determine the app target to place Expo.plist next to. Run from the root of an iOS-enabled project, or create Expo.plist yourself from the sample and add it to the target.
  • "… is not a valid plist (no closing ). — the existing Expo.plist is broken; fix or delete it and re-run init.
  • "Warning: missing required package(s): expo, expo-updates" — not fatal: the patching finishes, but install the packages (the printed commands match your lockfile) before building.

How do I build, publish and verify?

Build a release binary the way you always do — Gradle, Xcode, fastlane. OTA applies only to release builds; dev builds load JavaScript from Metro and never check the server. Changing the runtime version in the native config (EXPO_RUNTIME_VERSION / EXUpdatesRuntimeVersion) is a native change: edit those files and rebuild.

Like managed apps, a bare app must send a stable device id or the server answers 400 deviceId is required. The JS API is the same — Updates.setExtraParamAsync("deviceid", id) once at startup with a persistent id; the snippet and the lowercase-key warning are in Expo setup.

Publishing works identically to the managed flow — from the project root:

otapush publish --channel prod --platform all --message "Fix typo"

For bare projects, instead of expo export the CLI runs npx react-native bundle --platform <p> --dev false --entry-file <index.js|index.ts> --bundle-output dist-ota/bare-<p>/bundle.js --assets-dest …, collects the emitted assets recursively (react-native nests them: assets/node_modules/..., drawable-*), and uploads everything under the same contract. Verification is the same two-launch dance as in the Quickstart: first launch downloads, second runs.

How does bare differ from the managed flow?

  • The config surface. app.json + expo prebuild is replaced by direct native files — that is all; there is no difference in what the server can serve.
  • Standard layouts only. init patches the standard RN project layout; anything exotic needs the manual samples above.
  • Runtime version bumps are manual edits to the native config plus a rebuild — there is no app.json field to change.

Everything else — channels, publishing, promote, rollback, code signing, the device id requirement — is identical.

Questions and answers

Does the server treat a bare app differently from an Expo app?

No. The protocol neither knows nor cares how the client was built; a bare app gets the same manifests, directives and signatures. The only differences are in where the configuration lives on the client.

How do I send the device id from a bare app?

Exactly as in a managed app — the expo-updates JavaScript API is the same: await Updates.setExtraParamAsync("deviceid", id) with a stable id from expo-application. See Expo setup for the snippet and the reason the key must be lowercase.

Can otapush init patch a monorepo with non-standard paths?

No. It patches android/app/src/main/AndroidManifest.xml and ios/<ProjectName>/Expo.plist and errors out otherwise — deliberately, rather than guessing. Point it at a checkout with the standard layout, or apply the samples from "What exactly gets patched?" by hand.

Which channel does a bare build check?

The one in its URL, same as managed builds: the plain manifest URL means prod. Append ?channel=<name> to EXPO_UPDATE_URL / EXUpdatesURL for builds that should follow another channel.