Expo & EAS: shipping React Native
Hand-off — the app vs its motion. This skill owns the Expo platform: router, config plugins, EAS,
native modules, store builds. Animation and gestures — Reanimated worklets, the UI runtime,
sheets, screen transitions, press feedback, haptics, motion that stutters on a real device — are
../motion-craft/SKILL.md's.
What this skill owns
Expo is the toolchain and EAS cloud platform layered on React Native: cloud builds, store submission, over-the-air JS updates, and native configuration declared in JavaScript instead of hand-edited Xcode/Gradle projects. This skill owns the shipping pipeline and native-config-via-JS.
The verb rule of thumb: if the verb is build / submit / update / prebuild /
plugin / EAS / channel / runtime-version, you are in expo. If it is render /
navigate / animate / bridge / write a native module, route to react-native.
Other exits: web React/hooks/state → react, a Dart app → flutter, native-only
Swift/Kotlin → swift-ios/kotlin-android, a desktop wrapper → tauri/electron,
CI unrelated to EAS → github-actions.
Current stable as of 2026-06-02: Expo SDK 55 — React Native 0.83.1, React 19.2.0, shipped 2026-02-25. SDK 56 is in beta (beta opened 2026-05-06, ~2-week window; RN 0.85.2, React 19.2.3) — upcoming, not yet shipped stable. Both run exclusively on the New Architecture; the Legacy Architecture was removed in SDK 55.
Decision rules
| Situation | Do this | Why / not that |
|---|---|---|
| New app, want config-as-JS | managed + prebuild (CNG) — no committed ios//android/ |
native dirs are regenerable artifacts; hand-edits get blown away |
| You truly need to hand-edit native code long-term | bare (commit ios//android/) |
last resort; you lose prebuild --clean upgrades |
| Quick demo, only Expo-SDK modules | Expo Go | zero build, but custom native deps will crash |
| Any custom native dependency or plugin | dev build (developmentClient: true) |
Expo Go cannot load arbitrary native code |
| Runtime version, picking a policy | fingerprint (auto-bumps on native change) |
safest default; prevents serving JS to an incompatible binary |
| Runtime tied to your release version | appVersion |
simpler, but you must remember to bump it on native changes |
The shipping pipeline (core)
Four EAS verbs, in this order: prebuild → build → submit → update.
npx expo prebuild --clean # regenerate native dirs from app config + plugins (CNG)
eas build -p ios --profile production # cloud-build the binary (.ipa/.aab)
eas submit -p ios --profile production # upload to App Store / Play Store
eas update --branch production --message "fix typo" # ship JS-only over the air
prebuild is only needed for managed/CNG apps and runs automatically inside
eas build; run it locally to inspect or to verify a plugin. Build/submit ship a
new binary; update ships JS only — anything touching native code needs a new build.
Minimal eas.json with the three default profiles plus channels. Each build is
stamped with a channel; a channel maps to a same-named EAS Update branch by default
(SDK 55 stable builders: RN 0.83.1, Xcode 26 on iOS, Android 16 target):
{
"cli": { "version": ">= 16.0.0" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development"
},
"preview": {
"distribution": "internal",
"channel": "preview"
},
"production": {
"channel": "production",
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}
Run scripts/verify.sh inside an Expo project to gate eas.json, the runtime
policy, committed secrets, and New-Arch readiness.
EAS Update mental model
Runtime version is the compatibility gate. An update applies to a build only
when BOTH the platform AND the runtimeVersion match exactly — there is no
"close enough". The chain is: build stamped with a channel → channel points at a
branch → you publish updates to a branch. Updates flow only down a matching
runtime within the linked branch.
The #1 footgun: a hardcoded runtimeVersion that drifts from the binary, so the
update silently never installs and you stare at unchanged devices.
// BAD — hardcoded string in app.json; bump a native dep and every old build
// silently stops matching, so your "shipped" OTA never reaches users.
{ "expo": { "runtimeVersion": "1.0.0" } }
// GOOD — fingerprint policy: EAS hashes the native runtime and auto-bumps the
// runtime version whenever native code/config changes, so updates only ever
// reach binaries that can actually run them.
{ "expo": { "runtimeVersion": { "policy": "fingerprint" } } }
Inspect, branch ops, rollouts/rollbacks, republish, and the full "update not
applying" decision flow → references/eas-update.md.
Config plugins / CNG
Never hand-edit ios/ or android/ — they are ephemeral, regenerated by
npx expo prebuild --clean from app.config + plugins + autolinking. To change
native config, write a config plugin (a function in the plugins array) or use a
mod like withInfoPlist / withAndroidManifest.
// app.plugin.ts — add an iOS Info.plist key during prebuild, the CNG way.
import { ConfigPlugin, withInfoPlist } from "expo/config-plugins";
const withCameraUsage: ConfigPlugin<{ reason: string }> = (config, { reason }) =>
withInfoPlist(config, (cfg) => {
cfg.modResults.NSCameraUsageDescription = reason;
return cfg;
});
export default withCameraUsage;
// app.config.ts — dynamic config; reference the plugin with its options.
export default {
expo: {
name: "MyApp",
runtimeVersion: { policy: "fingerprint" },
plugins: [["./app.plugin.ts", { reason: "Scan receipts" }]],
},
};
Plugin anatomy, dangerous mods, mod ordering, and prebuild troubleshooting →
references/config-plugins.md.
New Architecture & SDK upgrade
The Legacy Architecture is gone. SDK 54 was the last release to ship it; SDK 55
(2026-02-25, current stable) removed it entirely, and the SDK 56 beta builds on that.
On any supported SDK you are already on the New Architecture — it is always
enabled and cannot be turned off. The newArchEnabled flag was deleted from
app.json in SDK 55; if you still carry one, it is dead config — remove it. There is no legacy
fallback to lean on, so a dependency that only works on the old architecture is now a
hard blocker, not a "flip the flag back" escape hatch.
Upgrade checklist:
npx expo install expo@latest --fix— bump SDK and align every dependency.npx expo-doctor@latest— catch deps that never made the New-Arch jump before you build.npx expo prebuild --clean— regenerate native dirs (managed/CNG apps).- Build a
development/previewbinary on a device before promoting to production.
Two upgrade tripwires: every Android app is edge-to-edge (on since SDK 54, non-negotiable) — audit manual inset/status-bar code. And SDK 55+ ships Hermes bytecode diffing for EAS Update (~75% smaller OTA downloads) automatically; you get it for free once both the build and the update are on SDK 55+.
Credentials & secrets
- Let EAS manage credentials (signing keys, provisioning profiles) by default — it stores and rotates them server-side so they never touch the repo.
- Supply per-profile config through EAS environment variables /
.envfiles scoped by profile, not committed plaintext. - Never commit a keystore (
*.jks/*.keystore),*.p12, or*.mobileprovision, and never put API keys inapp.config/app.json— anything in app config ships inside the public bundle. See../secure-coding/SKILL.md.
EAS Workflows
EAS Workflows are Expo's own CI: YAML in .eas/workflows/. Jobs use pre-packaged
types (build, submit, update) and chain via needs: + outputs. Route to the
github-actions skill only when the user explicitly wants GH Actions or
non-Expo CI.
# .eas/workflows/release-android.yml — build then submit, chained by needs.
name: Release Android
on:
push:
branches: [main]
jobs:
build:
type: build
params:
platform: android
profile: production
submit:
needs: [build]
type: submit
params:
platform: android
build_id: ${{ needs.build.outputs.build_id }}
Plan limits (set expectations)
EAS Free: 15 Android + 15 iOS builds/month on the low-priority queue only (peak waits can exceed an hour). High-priority queue needs a paid plan; the Production plan includes 2 build concurrencies, with extra concurrency at $50/concurrency/month, up to 5 extra. If a user complains about build queue waits, the fix is usually the plan, not the config. (Pricing per expo.dev/pricing, verified 2026-06-02; re-check before quoting — Expo adjusts tiers and dollar figures.)
Anti-patterns
| Anti-pattern | Do instead |
|---|---|
Editing ios/Info.plist directly |
prebuild --clean overwrites it; write a config plugin / withInfoPlist. |
Hardcoding runtimeVersion: '1.0.0' because it is simpler |
it drifts from the binary; updates silently stop matching. Use the fingerprint policy. |
| Expecting an OTA update to deliver a bumped native dep | EAS Update is JS-only; native changes need a new eas build. |
| Telling users to "just refresh" when a published update does not land | check the channel→branch and exact runtime match first — wrong channel = no delivery. |
| Testing a custom native module in Expo Go | Expo Go can't load arbitrary native code; build a dev client. |
| Upgrading the SDK and building straight to production | run expo-doctor + a preview build first; there is no Legacy-Arch fallback to catch a New-Arch-incompatible dep. |
Setting newArchEnabled: false to dodge a broken native dep |
the flag was removed in SDK 55 and the Legacy Architecture is gone; fix or replace the dep. |
| Committing the keystore so CI can sign | never; let EAS manage credentials or use EAS secrets. |
Putting the API key in app.config extra |
app config ships in the public bundle; use EAS env vars / a backend. |
Reaching for GitHub Actions to call eas build |
EAS Workflows is the native CI; only reach for github-actions if explicitly required. |
Project grounding (02-DOCS + CLAUDE.md)
In a project with a 02-DOCS/ layer (the harness wiki),
read 02-DOCS/wiki/stack/expo.md first and record this app's shipping decisions
there — managed-vs-bare, runtime-version policy, channel/branch map, SDK/New-Arch
status — linked from the root CLAUDE.md ## Knowledge map, bumping its Updated
date when a convention changes. No 02-DOCS/? Skip silently. Conventions are
recorded, not gated — never block the task on this.