Use when shipping a React Native app with Expo — EAS Build/Submit/Update, eas.json profiles and channels, config plugins, prebuild/CNG, runtime-version policy, OTA updates that never land, SDK upgrades, the New Architecture. NOT RN UI, navigation or native-module authoring (that is `react-native`), NOT a Dart app (that is `flutter`).
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ericrisco/rsc-harness --skill expo --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Expo?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-expo)More formats (shields.io, HTML) on the badges page.
---
name: expo
description: "Use when shipping a React Native app with Expo — EAS Build/Submit/Update, eas.json profiles and channels, config plugins, prebuild/CNG, runtime-version policy, OTA updates that never land, SDK upgrades, the New Architecture. NOT RN UI, navigation or native-module authoring (that is `react-native`), NOT a Dart app (that is `flutter`)."
tags: [expo, eas, react-native, mobile, ota-updates, app-store]
recommends: [react-native, github-actions, ship, deployment, secure-coding]
origin: risco
---
# 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**.
```bash
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):
```jsonc
{
"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.
```jsonc
// 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" } }
```
```jsonc
// 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`.
```ts
// 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;
```
```ts
// 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:
1. `npx expo install expo@latest --fix` — bump SDK and align every dependency.
2. `npx expo-doctor@latest` — catch deps that never made the New-Arch jump before you build.
3. `npx expo prebuild --clean` — regenerate native dirs (managed/CNG apps).
4. Build a `development`/`preview` binary 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** / `.env` files
scoped by profile, not committed plaintext.
- **Never commit** a keystore (`*.jks`/`*.keystore`), `*.p12`, or
`*.mobileprovision`, and never put API keys in `app.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
`type`s (`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.
```yaml
# .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`](../harness/SKILL.md) 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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!