EAS Build
EAS Build compiles signed iOS and Android binaries on Expo's machines, driven by named profiles in eas.json.
eas build --platform ios|android|all --profile <profile-name>A release is not JavaScript. It is an .ipa for iOS or an .aab for Android, compiled from your native project and signed. Compiling for iOS needs Xcode, which runs only on macOS. EAS Build runs that compile on Expo's machines and returns a download link, so an iOS build no longer requires owning a Mac.
Profiles
Named build profiles live in eas.json. A profile decides what comes out. developmentClient: true gives a build that loads JavaScript from your development server. distribution: "internal" gives a build testers install from a link. The default, distribution: "store", gives one the stores accept. extends lets a profile inherit another's fields, and channel ties the build to an update channel.
Running one
Run eas build --platform ios --profile production. The job queues, then streams logs. --platform all does both; eas build:list shows earlier builds.
What trips people up
A development build is a real installed app, not Expo Go, and it goes stale: add a library with native code and you build again. On iOS an internal build installs only on devices registered in its provisioning profile at build time. And android.buildType: "apk" is for sideloading; Play requires "app-bundle", the default.
Example
// app.config.ts - one project, three variants, chosen by APP_VARIANT
import type { ConfigContext, ExpoConfig } from "expo/config";
type Variant = "development" | "preview" | "production";
// APP_VARIANT is set per profile in eas.json (and in .env.local when running locally).
const variant = (process.env.APP_VARIANT ?? "development") as Variant;
const names: Record<Variant, string> = {
development: "Trailhead (dev)",
preview: "Trailhead (preview)",
production: "Trailhead",
};
// Separate identifiers let all three variants sit on one phone at once.
const ids: Record<Variant, string> = {
development: "com.example.trailhead.dev",
preview: "com.example.trailhead.preview",
production: "com.example.trailhead",
};
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: names[variant],
slug: "trailhead",
version: "1.0.0",
ios: { bundleIdentifier: ids[variant], supportsTablet: true },
android: { package: ids[variant] },
extra: { ...config.extra, variant },
});When to use it
- Produce an iOS build for TestFlight from a Windows or Linux machine.
- Hand a tester an install link without going through a store review.
- Keep a dev, a preview and a production copy of the app on the same phone.
- Rebuild after installing a library that contains native code.
More examples
eas.json with three profiles
The three profiles most teams end up with. development builds a dev client for your own device; preview builds an installable binary for testers, as an .apk on Android because that sideloads; production builds the store artifact and bumps the build number. Reach for extends whenever two profiles differ by one or two fields.
{
"cli": { "version": ">= 16.0.0", "appVersionSource": "remote" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"env": { "APP_VARIANT": "development" }
},
"preview": {
"extends": "development",
"developmentClient": false,
"channel": "preview",
"env": { "APP_VARIANT": "preview" },
"android": { "buildType": "apk" }
},
"production": {
"channel": "production",
"autoIncrement": true,
"env": { "APP_VARIANT": "production" }
}
},
"submit": { "production": {} }
}Showing testers which build they have
On screen this is a small orange capsule reading PREVIEW or DEVELOPMENT, and nothing at all in a production build. Useful once testers have two builds installed and file a report without saying which one they were in. Note borderRadius on a Text needs overflow: "hidden" on iOS, which is not how CSS behaves.
// VariantBadge.tsx - a label that disappears in production builds
import Constants from "expo-constants";
import { StyleSheet, Text } from "react-native";
export default function VariantBadge() {
// extra came from app.config.ts, so its type is not known to TypeScript.
const variant = Constants.expoConfig?.extra?.variant as string | undefined;
if (!variant || variant === "production") {
return null;
}
return <Text style={styles.badge}>{variant.toUpperCase()}</Text>;
}
const styles = StyleSheet.create({
badge: {
backgroundColor: "#B45309",
color: "#FFFFFF",
fontSize: 11,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
overflow: "hidden", // needed on iOS for borderRadius to clip a Text
},
});The commands around a build
The everyday loop. --auto-submit chains submission onto the build so you do not have to come back later, and build:list is where you find the build id that a submission or a bug report refers to.
# One-time: create eas.json and link the project to an EAS account
eas build:configure
# A build for testers; the link it prints installs on a registered device
eas build --platform android --profile preview
# A store build for both platforms, submitted as soon as it finishes
eas build --platform all --profile production --auto-submit
# What was built, by whom, and from which commit
eas build:list --platform ios --limit 5
Discussion