# Expo

> 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`).

- Skill: `ericrisco/expo` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add ericrisco/expo`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ericrisco/expo/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: ericrisco (https://skillmd.com/u/ericrisco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ericrisco/expo

---


# 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.

