Ship an Expo App With EAS Build and Submit
EAS Build compiles your Expo app in the cloud (iOS included, no Mac or Xcode) and EAS Submit uploads the result to both stores from your terminal. You describe it in one eas.json file with three profiles: development, preview, and production. This guide covers that pipeline, then the real battle scars, because version pinning is where projects bleed days.
Everything below traces to a real shipped app (SparkQuest, live on iOS and Android). The war stories are from its git history and its lessons file, not invented.
The pipeline in one view
Three commands carry an Expo app to the stores:
eas build --profile production --platform ios # cloud build, returns an .ipa
eas build --profile production --platform android # cloud build, returns an .aab
eas submit --profile production --platform ios # uploads to App Store Connect
eas submit --profile production --platform android # uploads to Play Console
Build runs on Expo's machines, including macOS runners for iOS, so you never own a Mac or open Xcode. Submit uses stored credentials to deliver the binary. On top of these, EAS Update pushes over-the-air JavaScript updates to already-installed apps, so a copy fix or a small logic change does not need a new store review.
eas.json: three profiles that do the work
The whole build configuration lives in eas.json. The shape that matters is three profiles, each with a job:
{
"cli": { "version": ">= 18.0.0", "appVersionSource": "local" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": { "buildType": "apk" }
},
"preview": {
"distribution": "internal",
"android": { "buildType": "apk" }
},
"production": {
"android": { "buildType": "app-bundle" },
"env": {
"EXPO_PUBLIC_SUPABASE_URL": "https://your-project.supabase.co",
"EXPO_PUBLIC_REVENUECAT_APPLE_KEY": "appl_...",
"EXPO_PUBLIC_REVENUECAT_GOOGLE_KEY": "goog_..."
}
}
},
"submit": {
"production": {
"android": { "track": "internal", "releaseStatus": "completed" },
"ios": { "appleTeamId": "YOURTEAMID", "ascAppId": "1234567890" }
}
}
}
The three profiles map to three moments. Development builds a debug client with the dev menu, for daily work on a device. Preview builds an internal-distribution binary (an APK on Android) you can hand to testers without going through the store. Production builds the store artifact: an Android app-bundle (.aab) for the Play Console, and the release iOS build.
One line in there is a scar we will come back to: the RevenueCat and Supabase keys live in the production profile's env block. Public environment variables have to be present at build time or they are simply absent in the shipped app. Putting them here, not just in a local file, is what makes the production build actually configured.
Version pinning, or: the Reanimated and New Architecture battle
Here is the part the happy-path tutorials skip. Expo SDK 54 ships React Native 0.81 and, from SDK 53 onward, the New Architecture is on by default. Reanimated 4 only supports the New Architecture and requires a separate package, react-native-worklets. Reanimated 3 is for the legacy architecture. That fork is a trap you can fall into from either side, and we fell into it repeatedly before we understood the shape of it.
What actually happened, in order, from the repo history:
- We started on Reanimated 4.x with the New Architecture. Builds worked, then a dependency shuffle quietly downgraded us to Reanimated 3.x while the New Architecture was still on. That mismatch does not build.
- We tried the other bank of the river: Reanimated 3.19.5 on the legacy architecture, which officially supports React Native 0.81. That is a genuinely valid combination and we kept it as a documented fallback ("golden config": legacy architecture plus Reanimated 3.19.5).
- We hit a Gradle trap. Gradle 8.14.3 broke the Android builds; we pinned Gradle to 8.10.2 with a small config plugin and the Android builds came back.
- We hit the sharpest one: the React version has to match the React Native renderer exactly. React 19.2.4 against React Native 0.81.6 produced an instant crash on launch. Pinning React back to 19.1.4 to match the 0.81.6 renderer fixed it. This is recorded in the project's lessons as Lesson 12, because it cost real time twice.
- We learned a hard compatibility fact: Reanimated 3.16.7 is incompatible with React Native 0.81.6. Use 3.19.x on legacy, or 4.x on the New Architecture. Nothing in between.
The build we finally trusted, and the one the project's lessons file literally calls "the gospel," is: React 19.1.4, React Native 0.81.6, Reanimated 4.1.6, react-native-worklets 0.7.4, New Architecture enabled. That combination shipped.
The transferable lesson is not the exact numbers, which will move with the next SDK. It is this: react, react-native, and your native animation libraries are one interlocked set. Change them together, to a combination someone has confirmed builds, or not at all. Bumping one package because a changelog looked nice is how you spend a weekend bisecting build logs.
Two more traps worth naming
First, the New Architecture flag has to agree in two places. It is set in app.json (via expo-build-properties) and it also lives in gradle.properties on Android. If those two disagree, the build behaves unpredictably. Check both.
Second, patch the right file when you monkey-patch a native module. We hit a Reanimated crash where an unmounting animated component could not find its host instance and threw. Metro bundles a library from its src/ field, not from lib/module/, so a patch applied to the compiled output does nothing. Our fix was a small postinstall script that rewrites the TypeScript source to warn and return safely instead of throwing:
// scripts/postinstall.js (abridged)
const filePath = path.join(
__dirname,
"../node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx"
);
// replace the thrown ReanimatedError with a warning + safe return,
// then guard so a second install does not double-patch.
Wiring it as a postinstall script means the patch reapplies every time node_modules is rebuilt, so a fresh install on a CI machine is not silently broken. The two rules from this: patch src/, not lib/module/, and make the patch idempotent.
Submitting to both stores
Once a production build exists, EAS Submit delivers it. For iOS you authenticate with an App Store Connect API key (an issuer ID, a key ID, and a .p8 file), which is cleaner than app-specific passwords and works headless. For Android you use a Google Play service account JSON, and you point the submit config at a track, usually internal first so the build lands in your internal testing lane before you promote it.
Two operational notes from doing this for real. Keep your build numbers straight: EAS can auto-increment, but if your local app.json drifts behind what the Play Console already has, the submit is rejected for a duplicate version code. And an EAS Submit that times out is not the same as a failed upload. More than once ours reported a timeout while the build had in fact reached the Play Console. Check the store console before you retry, or you queue a duplicate.
Credentials: let EAS hold the signing keys
Native app signing is a classic source of lost afternoons: iOS distribution certificates and provisioning profiles, Android keystores, all of which have to be exactly right or the build is worthless. EAS manages these for you. On the first build it offers to generate and store the signing credentials, and on every build after that it reuses them. For iOS you supply an App Store Connect API key once; for Android you generate a keystore (let EAS keep it) and drop in a Google Play service account JSON for submission. The practical rule: let EAS own the credentials rather than juggling certificate files by hand, and keep the Android keystore backed up, because losing it means you can never update that app listing again.
EAS Update: what you can push over the air
EAS Update sends new JavaScript and assets to already-installed apps without a store review. Change a string, fix a layout, adjust a bit of logic, publish an update, and users get it on next launch. It is the reason a small copy fix does not need a two-day review cycle.
The honest boundary: over-the-air updates are for your JavaScript and assets, not for native code. Anything that changes the native layer (a new native module, a change to app.json build properties, a dependency with native code, the New Architecture flag) requires a fresh EAS Build and a new store submission. Both stores also require that updates not materially change what the app does versus what they reviewed, so OTA is for fixes and refinements, not for shipping a different app around review. Keep the update channel tied to the build profile so a preview build pulls preview updates and production pulls production, and you avoid pushing a half-finished change to real users.
The preflight checklist we run before every build
Because each cloud build costs credits (and, during the Reanimated fight, we watched most of a build-credit allotment disappear in one stretch), we run a short preflight before spending one. The real checklist from the project:
- Disk space: a local build needs several GB free; check before you start or the build dies late.
- React matches the renderer: confirm the installed react version matches what the React Native renderer expects. This is the Lesson 12 crash from earlier, caught before it costs a build.
- node_modules healthy: a broken install is better found now than in a cloud log.
- Google service account present: the submit step needs google-service-account.json in the project root, copied from wherever you keep it.
- Version numbers checked: read app.json and confirm the build number is ahead of the store.
- Git clean: commit or stash first, so a failed build maps to a known commit.
None of these is glamorous. Together they are the difference between a build that ships and a build that fails at minute twenty and takes a credit with it.
What EAS costs
The free tier gives you 15 iOS and 15 Android builds a month, OTA updates for up to 1,000 monthly active users, and 100 GiB of bandwidth. That is genuinely enough to ship a first app, though a stubborn build battle like the one above can eat into 30 builds fast. The Production plan is $199 a month with $225 of build credits, 50,000 monthly active users, and 1 TiB of bandwidth, then usage-based overage rates. Verified on expo.dev/pricing, July 2026.
The honest budgeting note: cloud builds are not free once you are iterating hard. Our own project burned through most of a build-credit allotment during the Reanimated fight in a single stretch. Get your version set pinned to a known-good combination before you start spending builds trying to find one.
Where this fits in the journey
EAS is how the binary reaches the store. Choosing the stack that goes into it is the indie hacker tech stack for 2026 and React Native vs Flutter. Wiring payments into that build is RevenueCat and Expo setup. And the full path from a finished build through review and testing gates to live on both stores is how to ship an app in 2026, with the fast-timeline version in idea to App Store in 30 days.
GenerateIdeas.app turns a validated idea into a build-ready master prompt, so the app you take through this pipeline is one worth shipping. Start with your idea
Questions from the field
- Do I need a Mac or Xcode to build an Expo iOS app?
- No. EAS Build compiles your iOS app on Expo's macOS cloud machines and hands you back an installable build, so you never open Xcode or own a Mac. You configure the build in eas.json, run eas build --platform ios, and eas submit uploads it to App Store Connect using an App Store Connect API key. The whole pipeline runs from a terminal on any operating system.
- How much does EAS Build cost in 2026?
- The free tier includes 15 iOS and 15 Android builds a month, OTA updates for up to 1,000 monthly active users, and 100 GiB of bandwidth. The Production plan is $199 a month and includes $225 of build credits, 50,000 monthly active users, and 1 TiB of bandwidth, then usage-based pricing for overages. For most solo launches the free tier is enough to ship. Verified on expo.dev/pricing, July 2026.
- What is the difference between EAS Build and EAS Submit?
- EAS Build compiles your source into a native binary (an .ipa for iOS, an .aab or .apk for Android) on Expo's cloud. EAS Submit takes that binary and uploads it to App Store Connect or the Google Play Console for you, using stored credentials. Build makes the app; Submit delivers it to the store. You typically run build first, then submit the resulting artifact.
- Why does my Expo app build fail after a dependency update?
- The most common cause is a version mismatch between react, react-native, and native libraries like Reanimated. Each React Native version expects a specific React renderer, and native modules like Reanimated 4 require the New Architecture plus react-native-worklets. Bumping one package without matching the others produces build failures or instant runtime crashes. The fix is to pin the whole set to a known-good combination and change them together, not one at a time.