EAS Build and EAS Submit are cloud services from Expo (Expo Application Services) that compile a React Native project into signed, store-ready binaries and upload them to the Apple App Store and Google Play from the command line. Instead of maintaining a local Xcode and Android toolchain, you define build profiles, typically development, preview, and production, in an eas.json file, and EAS runs the native build on managed cloud machines, handling code signing credentials such as iOS provisioning profiles and Android keystores for you. A typical release is two commands: eas build --platform all --profile production, then eas submit to push the result to TestFlight and Google Play internal testing. This guide walks the full pipeline: setup, eas.json profiles, credentials, app identity and versioning, running builds, submitting, fixing common build failures, and wiring EAS into CI.
For years, the hardest part of shipping a React Native app was not writing the app. It was everything after: provisioning profiles that expired at the worst possible moment, Xcode archive dialogs, keystores stored on a single laptop, and CI configs that broke every time Apple changed a rule. Expo Application Services (EAS) collapses that entire pipeline into a handful of commands. This post walks through the full journey from a local project to a build sitting in TestFlight and Google Play internal testing, with real configuration you can copy into your own project.
What Is EAS, Actually?
EAS is a set of cloud services from the Expo team that handle the operational side of a mobile app. It is not a framework and it does not change how you write React Native code. It is the machinery that turns your JavaScript project into signed, store-ready binaries. There are three services worth knowing.
The three EAS services:
- EAS Build - compiles your project into an .ipa (iOS) or .aab/.apk (Android) on managed cloud machines, handling native dependencies and code signing for you.
- EAS Submit - uploads a finished build directly to App Store Connect or the Google Play Console, so you never touch Transporter or the Play upload UI.
- EAS Update - ships over-the-air JavaScript and asset updates to already-installed apps without a new store review, for anything that does not require a native change.
This article focuses on Build and Submit, the two you need to get an app into the stores. EAS Update is worth a dedicated post, but keep in mind that Update only pushes JS and assets, never native code, so a new native module always requires a fresh Build and store submission.
Getting Set Up
Everything runs through the EAS CLI. Install it, log in with your Expo account, then run the configure command from your project root. That command inspects your project and generates a starter eas.json.
# Install the CLI globally
npm install -g eas-cli
# Authenticate with your Expo account
eas login
# Generate eas.json and link the project to EAS
eas build:configureThe configure step also writes an EAS project ID into your app config under extra.eas.projectId, which is how the CLI knows which cloud project this repository maps to.
How Do eas.json Build Profiles Work?
eas.json is where you declare build profiles. A profile is a named bundle of settings that describes how a particular kind of build should be produced. Almost every team ends up with the same three: development for local iteration, preview for internal QA and stakeholders, and production for the stores. Here is a complete, realistic file.
{
"cli": {
"version": ">= 12.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": true
},
"channel": "development"
},
"preview": {
"distribution": "internal",
"ios": {
"resourceClass": "m-medium"
},
"android": {
"buildType": "apk"
},
"channel": "preview"
},
"production": {
"autoIncrement": true,
"ios": {
"resourceClass": "m-medium"
},
"android": {
"buildType": "app-bundle"
},
"channel": "production"
}
},
"submit": {
"production": {
"ios": {
"appleId": "[email protected]",
"ascAppId": "6448291043",
"appleTeamId": "9ABCDE1234"
},
"android": {
"serviceAccountKeyPath": "./secrets/play-service-account.json",
"track": "internal"
}
}
}
}A few decisions in that file matter more than they look. The distribution key controls how the finished build is delivered: internal means an install link you can share with your team, while store means the build is destined for App Store Connect or Google Play. The channel key ties a build to an EAS Update channel, so a production binary only ever receives production over-the-air updates. And appVersionSource set to remote tells EAS to track your build numbers on its own servers rather than in your local files, which is what makes autoIncrement reliable across machines.
Development Builds vs Expo Go
Expo Go is the sandbox app you download from the store to preview a project instantly. It is fantastic for demos and tutorials, but it ships with a fixed set of native modules. The moment you add a library with custom native code, react-native-vision-camera, a payments SDK, a custom config plugin, Expo Go can no longer run your project. That is what the development profile above solves. Setting developmentClient to true produces a development build: your own version of Expo Go that contains exactly your native dependencies, while still connecting to the Metro bundler for fast JavaScript reloads. You install it once and iterate normally.
Rule of thumb: use Expo Go while you are only touching JavaScript and Expo SDK modules. Switch to a development build the first time you add a native dependency that Expo Go does not bundle.
Internal Distribution and Simulator Builds
Internal distribution is the fastest way to get a real build onto real devices without going through TestFlight or Google Play review. After the build finishes, EAS gives you a URL and a QR code. On Android the APK installs directly. On iOS the devices must be registered in your Apple Developer account first, which EAS handles interactively.
# Register iOS test devices with your Apple account
eas device:create
# Build a shareable internal preview for both platforms
eas build --profile preview --platform allThe ios.simulator flag in the development profile is a separate trick worth knowing. A normal iOS build is signed for physical hardware and will not run in the iOS Simulator. Setting simulator to true produces an unsigned .app you can drag straight into the Simulator, which is perfect for teammates on macOS who do not have a registered device.
How Does EAS Handle Credentials?
Code signing is historically where mobile releases go to die. EAS manages it end to end, and for most teams the right answer is to let it. The first time you run a build that needs signing, EAS offers to generate and store the credentials for you on its servers.
What EAS manages on each platform:
- iOS: the distribution certificate and the provisioning profile, plus push notification keys if you use them. EAS can create these against your Apple Developer account and reuse them across builds.
- Android: the upload keystore used to sign your .aab. EAS generates a keystore once and stores it securely, so it is never trapped on one developer laptop again.
- Everything is inspectable and exportable at any time with the credentials command, so you are never locked in.
# Interactive management: view, generate, or import certs and keystores
eas credentials
# Losing the Android keystore is unrecoverable, so back it up
eas credentials --platform androidThe Android upload keystore is the single most important secret in your project. If you lose it and did not enroll in Play App Signing, you can never update your app under the same listing again. Back it up the day you create it.
If your organization requires signing credentials to stay in house, EAS supports local credentials via a credentials.json file that points at your own certificate and keystore. But for the vast majority of projects, letting EAS hold the credentials removes an entire category of release-day panic.
App Config: Identity and Versioning
Your app.json or, better, a dynamic app.config.ts defines the identity of the app: its name, its bundle identifier on iOS, its package name on Android, and its version numbers. Using the TypeScript form lets you branch on environment variables, which is invaluable when you want a separate bundle identifier for a staging build installed alongside production.
# Conceptually, the two identity fields that must never change lightly:
ios:
bundleIdentifier: dev.tahakocal.myapp # permanent App Store identity
android:
package: dev.tahakocal.myapp # permanent Play Store identityHere is a dynamic config that gives development, preview, and production builds distinct identities and icons so they can coexist on one device.
import { ExpoConfig, ConfigContext } from 'expo/config';
const VARIANT = process.env.APP_VARIANT ?? 'production';
const identity = {
development: {
name: 'MyApp (Dev)',
bundleId: 'dev.tahakocal.myapp.dev',
},
preview: {
name: 'MyApp (Preview)',
bundleId: 'dev.tahakocal.myapp.preview',
},
production: {
name: 'MyApp',
bundleId: 'dev.tahakocal.myapp',
},
}[VARIANT];
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: identity.name,
slug: 'myapp',
version: '1.4.0',
orientation: 'portrait',
ios: {
bundleIdentifier: identity.bundleId,
supportsTablet: true,
},
android: {
package: identity.bundleId,
},
extra: {
eas: {
projectId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
},
},
});Two Kinds of Version Number
Mobile stores distinguish the human-facing version from the internal build number, and getting them confused causes rejected submissions. The version field is the marketing string users see, like 1.4.0. Separate from it, iOS has buildNumber and Android has versionCode, integers that must increase with every single upload even if the marketing version is unchanged. Forgetting to bump these is the most common submission rejection there is.
This is exactly why the production profile sets autoIncrement to true together with appVersionSource remote in the cli block. EAS keeps the current build number on its servers and increments it automatically on every production build, so two engineers building from two laptops never collide and you never think about it again.
How Do You Build for iOS and Android?
With profiles and config in place, building is a single command. EAS queues the job on a cloud machine, installs dependencies, runs the native build, signs it, and hands you a download URL. You can watch progress in the terminal or in the EAS dashboard.
# Production build for one platform
eas build --profile production --platform ios
# Both platforms in one queued job
eas build --profile production --platform all
# Non-interactive build for CI, no prompts
eas build --profile production --platform all --non-interactive --no-waitThe production Android profile above uses buildType app-bundle, producing an .aab, which is what Google Play requires for new apps. The preview profile uses apk instead because an APK installs directly on a device for quick internal testing without going through the Play Store. On iOS the production build is a store-signed .ipa ready for App Store Connect.
Submitting with eas submit
Once a build finishes, eas submit takes it the last mile. It reads the submit section of eas.json and uploads the binary straight to the store, no Transporter, no manual drag-and-drop into the Play Console.
# Submit the latest finished build for a profile
eas submit --profile production --platform ios --latest
# Or submit a specific build by ID
eas submit --profile production --platform android --id 3f2a91c4-...
# Build and submit back to back
eas build --profile production --platform ios --auto-submitFor iOS, EAS needs three things you saw in the submit config: your Apple ID, the App Store Connect app ID (ascAppId), and your Apple team ID. It authenticates using an App Store Connect API key, which is far more robust in CI than a password plus two-factor prompt. For Android, submission is driven by a Google Play service account JSON key with the right permissions in the Play Console; the track key decides whether the upload lands in internal, alpha, beta, or production.
TestFlight and Internal Testing
When an iOS build reaches App Store Connect it is not live to the public. It goes to TestFlight, Apple's beta distribution system. Internal testers, up to 100 people on your team, get the build almost immediately. External testers, up to 10,000, require a short Apple beta review first. On Android the equivalent is the internal testing track, which sets the track key to internal in the submit config and puts a build in front of your named testers within minutes. Both are the correct final gate before a public release: real devices, real store install flow, real crash reporting.
What Causes Common Build Failures, and How Do You Fix Them?
Cloud builds fail differently than local ones. After shipping a fair number of apps this way, these are the failures that come up again and again.
The recurring culprits:
- Build number already used - App Store Connect rejects a duplicate buildNumber or versionCode. Fix: enable autoIncrement with appVersionSource remote so it can never happen again.
- Provisioning profile does not match - a device or capability is not in the profile. Fix: run eas credentials and let EAS regenerate the profile, or add missing devices with eas device:create.
- Native dependency fails to compile - a library needs a config plugin or a specific SDK version. Fix: read the raw build logs in the dashboard, align the dependency with your Expo SDK version, and add any required plugin to app config.
- Out of memory during the JS bundle step - large apps exhaust the default machine. Fix: bump ios.resourceClass to m-medium or larger in eas.json.
- Missing environment variable at build time - secrets that exist locally are absent in the cloud. Fix: define them with eas secret or as build-profile env values, never assume your .env is uploaded.
- Metro or Gradle cache poisoning after an upgrade - stale caches cause phantom errors. Fix: rerun with --clear-cache to force a clean build.
# Store a secret that build jobs can read
eas secret:create --scope project --name SENTRY_AUTH_TOKEN --value "xxxx"
# Force a clean rebuild when caches misbehave
eas build --profile production --platform android --clear-cacheWiring It Into CI
The real payoff is automation. Because EAS holds your credentials and tracks build numbers, a CI job needs almost nothing beyond an access token. Generate an EAS access token in your Expo dashboard, store it as a CI secret named EXPO_TOKEN, and the CLI authenticates non-interactively. Here is a minimal GitHub Actions workflow that builds and submits on every push to main.
name: EAS Production Release
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Setup EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies
run: npm ci
- name: Build and submit
run: eas build --profile production --platform all --non-interactive --auto-submitExpo also offers EAS Workflows, a hosted CI system with the same idea but tighter integration, letting you define build, submit, and update pipelines in a YAML file that runs on Expo infrastructure. Either way, the principle holds: your credentials live in EAS, your version numbers auto-increment, and a merge to main can carry code all the way to TestFlight without a human touching Xcode.
Key Takeaways
What to remember:
- EAS splits into Build (make the binary), Submit (upload it), and Update (over-the-air JS changes). Build and Submit get you to the stores; Update never replaces a native change.
- Model your workflow with three eas.json profiles: development for a custom dev client, preview for internal distribution, and production for the stores.
- Reach for a development build the moment Expo Go can no longer run your native dependencies; use ios.simulator builds for teammates without registered devices.
- Let EAS manage credentials. The iOS certs and provisioning profiles regenerate cleanly, and the Android keystore lives somewhere safer than one laptop, but back it up regardless.
- Keep the marketing version separate from the build number, and set autoIncrement with appVersionSource remote so duplicate-build-number rejections disappear forever.
- eas submit with an App Store Connect API key and a Play service account turns store uploads into one command, landing builds in TestFlight and internal testing for a final real-device check.
- Once credentials and versioning are automated, CI is almost free: an EXPO_TOKEN secret plus one eas build --auto-submit line ships from a merge to the store.
