NextPush now speaks the Expo Updates protocol (beta)
Expo apps can point expo-updates at NextPush. What the protocol is, why you need a development or release build, the five-step setup, how disabling a release behaves in the beta, what is missing (code signing, rollback directive, dashboard UI), and how pricing works.
NextPush has served the CodePush protocol since 2025. As of today it also serves the Expo Updates protocol, in beta. If your app uses expo-updates, you can point updates.url at NextPush, build once, and publish JavaScript updates from the NextPush CLI with the same download-based plans as every other NextPush app. This post explains what the protocol is, what you have to build to use a custom server, the setup in five steps, how the beta behaves when you disable a release, and what is not there yet. It is a beta: the endpoint and the CLI command are live, the dashboard side is not, and we want your bug reports.
What the Expo Updates protocol is
Expo publishes the protocol its client speaks as a specification, which is what makes third-party servers possible. The pieces you need to know:
One manifest URL. The app has a single updates.url in its config. The client sends a GET to it and passes everything else as headers: expo-protocol-version, expo-platform (ios or android), expo-runtime-version, and expo-current-update-id, the UUID of the update the device is running. There is no platform or version in the path.
Runtime version. Native code and JavaScript have to agree. The runtimeVersion in your app config is either a literal string or a policy (appVersion, nativeVersion, fingerprint, sdkVersion) that the build and the export both resolve to a string. The server only ever hands a device an update that was exported for the runtime version the device reports. A build with runtimeVersion: { "policy": "appVersion" } and version: "1.4.0" will only receive updates published for 1.4.0.
The manifest and its assets. A manifest is a JSON object with an id (UUID), createdAt, runtimeVersion, a launchAsset (the Hermes bytecode bundle) and an assets array. Every asset carries a url, a key (MD5 hex of the bytes, which is how the bundle looks the asset up) and a hash (base64url SHA-256 of the bytes), which the client verifies after download. If a byte is wrong, the update is rejected on the device.
Protocol 0 and protocol 1. Protocol 0 is a bare JSON manifest: the server always returns the newest manifest and there is no way to say "nothing new". Protocol 1, which every current client (SDK 52 and later) sends, is a multipart/mixed response with named parts: a manifest part plus an extensions part when there is an update, or a single directive part such as {"type":"noUpdateAvailable"} when the device already has the newest one. NextPush answers both; if the request has no expo-protocol-version header it is treated as protocol 0, as the Expo reference server does.
Compared with CodePush, the model is different in one way that matters operationally: there is no "update check that reports status" round trip and no deployment-side notion of a target binary version. The runtime version does that job, and the device decides what to run from the manifests it holds, launching the newest by createdAt.
Why you need a development or release build
Expo Go cannot use a custom update server. Its native configuration is fixed, updates.url is a native value (EXUpdatesURL in Expo.plist, expo.modules.updates.EXPO_UPDATE_URL in AndroidManifest.xml), and the Updates JavaScript API is mostly unavailable in Expo Go. You need a binary that was built with your app.json: a development build with expo-dev-client, or a release build. Debug builds load JavaScript from Metro, not from the server, so to see an update arrive you build a release variant. EAS Build, npx expo run:*, or any CI that runs Xcode and Gradle all work; what matters is that the native project was generated from a config that already points at NextPush.
Setup in five steps
Everything below uses the deployment key model you already know from CodePush. An Expo update is stored against a deployment, so Staging and Production are two keys and two URLs.
1. Create the app and copy a deployment key
Create an app in the NextPush dashboard (or nextpush app add) and copy the key of the deployment you want this build to follow. Deployment keys are shown on the app page.
2. Configure expo-updates
npx expo install expo-updates
Then in app.json:
{
"expo": {
"version": "1.4.0",
"runtimeVersion": { "policy": "appVersion" },
"updates": {
"url": "https://api.nextpush.center/expo/<DEPLOYMENT_KEY>/manifest",
"enabled": true,
"checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 0
}
}
}
Do not set updates.codeSigningCertificate for the beta. When that key is present the client refuses unsigned responses, and NextPush does not sign yet (see Limitations). The appVersion policy is the simplest: bump version when you ship native changes and the two stay in step. The server stores whatever runtime version string you publish with, so any policy works as long as the exported value matches the build; the docs page lists which policies the beta CLI resolves for you and which you pass explicitly.
3. Build a binary that carries the URL
npx expo prebuild
npx expo run:android --variant release
# or
npx expo run:ios --configuration Release
Or build with EAS Build or your own CI. Any build made before step 2 does not know about NextPush and will never check it.
4. Publish an update
nextpush login
nextpush release-expo -a companyName/AwesomeApp -d Production
This is beta; the exact flags are documented at https://docs.nextpush.dev/expo-updates and may change before general availability. What the command does, so you can reproduce it in CI if you prefer: it runs npx expo export for both platforms, writes the public Expo config next to the export (npx expo config --type public --json, which becomes extra.expoClient in the manifest), zips the export directory, and uploads it with the runtime version to the deployment's expo/release endpoint. One upload produces one release group with an iOS and an Android update, each tied to the runtime version you exported for. A release that targets a runtime version no build reports is simply never served; that is the most common "it does nothing" cause, so check version first.
5. Verify on a device, or with curl
In the app:
import * as Updates from "expo-updates";
export async function applyNextPushUpdate() {
const check = await Updates.checkForUpdateAsync();
if (check.isAvailable) {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
}
}
checkForUpdateAsync() returns isAvailable: false with reason set when the server answered noUpdateAvailable, which is what you should see immediately after a fresh install that already matches the newest release. The useUpdates() hook exposes the same state plus download progress if you prefer a declarative check.
From a terminal, the same request the device makes:
curl -i https://api.nextpush.center/expo/<DEPLOYMENT_KEY>/manifest \
-H "expo-protocol-version: 1" \
-H "expo-platform: ios" \
-H "expo-runtime-version: 1.4.0"
You get a multipart/mixed body with a manifest part, or a directive part with noUpdateAvailable when nothing is published for that platform and runtime version. For quick checks the endpoint also accepts ?platform=ios&runtime-version=1.4.0 as query fallbacks. A 404 means the deployment key is wrong; a 400 names the header that is missing.
Rollouts and disabling in the beta
The beta has two controls, and it is worth being precise about what each does.
Deployments as channels. Staging and Production have different keys, so different URLs, so different builds. That is heavier than Expo's expo-channel-name header, which lets one binary switch channels, and it is on the list below. For now, a staging build and a production build is the model.
Disable a release group. Each upload is a release group; a group can be marked disabled, and re-enabled, through the CLI or the management API (the docs page has the current command). The server skips disabled groups and serves the newest enabled release for that platform and runtime version, or noUpdateAvailable if none is left. Disabling stops new devices from downloading the release. It does not move devices that already installed it: the Expo client launches the newest update it holds by createdAt, and the protocol's mechanism for sending it backwards is a rollback directive, which the beta does not emit. To move users off a bad release today, publish a new release. In practice that is what you would do anyway, since the fix is a new bundle.
No percentage rollouts yet. CodePush deployments on NextPush support --rollout 25; Expo releases do not in the beta. Every device on the runtime version sees the new release on its next check.
Limitations
Stated plainly, so nobody discovers them in production:
- No code signing. Responses are unsigned. If the client sends
expo-expect-signature, the server adds anx-nextpush-warningheader and answers unsigned, which a client configured with a certificate will reject. LeavecodeSigningCertificateout until signing ships; it is the first item after the beta. - No
rollBackToEmbeddeddirective. See above. Publishing a new release is the way back. - No dashboard UI. Releases, disabling and listing are CLI and API only. The dashboard shows the deployment and its key, not the Expo release history, yet.
- No channel header.
expo-channel-nameis ignored; the deployment key in the URL selects the deployment. - Full downloads. Assets are served as complete files from our CDN. The bsdiff patch negotiation that newer clients offer for the launch asset is not implemented; the client falls back to a full download, which is the protocol's defined behaviour.
- Protocol 0 is minimal. It returns a bare manifest or a 404. All current clients use protocol 1; protocol 0 exists for completeness.
Pricing
Expo updates are on the same plans as CodePush updates and are counted the same way: an update is one device downloading one release. Free is 1 app and 1,000 updates a month; Plus is $24 a month for 25 apps and 100,000 updates; Enterprise is $60 a month for 1,000,000 updates, with each additional 1,000,000 at $15. There is no per-user charge, so the arithmetic in our cost post applies unchanged to Expo apps. During the beta we will not bill Expo updates differently from CodePush updates, and we will say so here first if that changes.
We want beta feedback
Point a development build at NextPush, publish an update, and tell us what broke or what was unclear. The most useful reports include the Expo SDK version, the runtimeVersion policy, the platform, and the curl output from step 5. Send them to [email protected], and watch https://docs.nextpush.dev/expo-updates for the current CLI reference and the changelog as the beta moves toward general availability.
Sources
- Expo Updates protocol specification, version 1: https://docs.expo.dev/technical-specs/expo-updates-1/
- Expo, hosting updates on a custom server (development build requirement): https://docs.expo.dev/eas-update/custom-updates-server
- Expo reference implementation,
expo/custom-expo-updates-server: https://github.com/expo/custom-expo-updates-server expo-updatesSDK API (checkForUpdateAsync,fetchUpdateAsync,reloadAsync,useUpdates, Expo Go limitations): https://docs.expo.dev/versions/latest/sdk/updates/- Expo app config reference (
updates.url,runtimeVersion,codeSigningCertificate): https://docs.expo.dev/versions/latest/config/app/ - Expo runtime version policies: https://docs.expo.dev/eas-update/runtime-versions/
- Expo code signing: https://docs.expo.dev/eas-update/code-signing/
- Expo CLI (
expo export,expo config,expo run): https://docs.expo.dev/more/expo-cli/ - NextPush docs, Expo Updates (beta): https://docs.nextpush.dev/expo-updates
- NextPush pricing: https://nextpush.center/#pricing