# Expo Production Operation Guide 2026: Expo Router, CNG, EAS, and OTA Updates Explained in Real Code

> A practical guide to fully using Expo SDK 56 (React Native 0.85 / React 19.2, New Architecture standard) in production. Explained with working code from a real monorepo: Expo Router v7's file-based routing, CNG (Continuous Native Generation) and config plugins, EAS Build/Update/Submit/Workflows, and the runtimeVersion design of OTA updates that prevents accidents.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: React Native, Expo, TypeScript, EAS, CI/CD, モバイルアプリ, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/expo-production-guide-router-eas-cng-ota
- Category: Frontend
- Pillar guide: https://tomodahinata.com/en/blog/nextjs-16-app-router-cache-components-data-fetching

## Key points

- app.config.ts is the single truth. android/ and ios/ are artifacts, not committed to git, and managed declaratively with CNG + config plugins
- Expo Router v7 is file structure = screen transitions. All screens automatically support deep links, and you can make transitions type-safe with typedRoutes
- Production builds without a Mac and store submission with EAS Build/Submit. Use the 3 profiles development / preview / production
- What you can send with OTA (EAS Update) is only JS and assets. The runtimeVersion fingerprint policy structurally prevents mis-delivery of native changes
- Don't bake secrets into the bundle. The EXPO_PUBLIC_ prefix is baked in as plaintext, so store sensitive information in expo-secure-store

---

"Expo is for prototypes, and for a serious app you eject in the end" — this is **an outdated perception now.** Expo in 2026 has become the officially recommended framework replacing `react-native init`. I myself built a [real-time game-scoring app](/case-studies/realtime-sports-scoring-app) for amateur baseball (iOS / Android mobile + a Web admin panel) alone in an **Expo + Next.js + Supabase monorepo**, and operate it from store distribution to OTA updates.

This article explains "**which feature, when, and how to use it**" gained from that real operation, faithfully to the Expo official documentation (as of [SDK 56](https://expo.dev/changelog/sdk-56)), but with **a thicker decision axis** than the official docs. All code is posted at a granularity you can actually move your hands with.

> The reference version of this article: **Expo SDK 56** (React Native 0.85 / React 19.2, Hermes v1 as the default engine). Since SDK 55, **the Legacy Architecture was removed and the New Architecture is always enabled** (can't be disabled). Expo Router reached **v7** and became independent of React Navigation.

## 0. The big picture: how to combine Expo's "4 pillars"

Using Expo in production effectively means designing these 4. Confuse them and you'll have accidents.

| Pillar | Role | Central tool | This article's section |
| --- | --- | --- | --- |
| **Screen transitions** | Routing, deep links | Expo Router v7 | §2 |
| **Native layer** | iOS/Android project generation | CNG / prebuild / config plugin | §3 |
| **Build & distribution** | Cloud builds and store submission | EAS Build / Submit | §4 |
| **Zero-downtime updates** | JS updates without going through review | EAS Update (OTA) | §5 |

The order has meaning. **Decide the app's shape with Router, manage native declaratively with CNG, build with EAS, and fix it during operation with Update.** Below, we proceed in this flow.

---

## 1. Mental model: app.config.ts is the single truth

The first point you should switch your head on is here. The idea that **the `android/` and `ios/` directories are "artifacts," not "source."**

Conventional React Native creates `android/` `ios/` once and grows them by hand. With Expo's **CNG (Continuous Native Generation)**, you **regenerate the native projects from the settings every time.** The generation source is the single `app.config.ts`.

```ts
// app.config.ts — アプリの「宣言的な唯一の真実」
import type { ExpoConfig } from 'expo/config';

export default (): ExpoConfig => ({
  name: 'Scorebook',
  slug: 'scorebook',
  scheme: 'scorebook', // ディープリンク・OAuthリダイレクトの基点
  version: '1.4.0',
  orientation: 'portrait',
  newArchEnabled: true, // SDK 56では既定。明示しておくと意図が伝わる
  ios: { bundleIdentifier: 'com.example.scorebook', supportsTablet: true },
  android: { package: 'com.example.scorebook', edgeToEdgeEnabled: true },
  plugins: [
    'expo-router',
    'expo-secure-store',
    ['expo-build-properties', { ios: { deploymentTarget: '15.1' } }],
  ],
  experiments: { typedRoutes: true }, // §2で使う型安全ルーティング
});
```

The practical benefit this brings is that **an SDK upgrade changes from "the work of reading the diff of native code someone else wrote" to "just raise the version in package.json and regenerate."** With `npx expo prebuild --clean` you can throw away `android/` `ios/` and re-create them.

> **Decision axis: should you commit `android/`/`ios/` to git?**
> If you use CNG, the principle is to **not commit them** (put them in `.gitignore`). Commit the artifacts and double management of "settings vs. artifacts" begins, and CNG's benefit disappears. If a hand-written native change becomes necessary, the correct answer is to **lean it to the settings side** with §3's config plugin.

---

## 2. Expo Router v7: use file-based routing correctly

Expo Router is a mechanism where **the file structure of the `app/` directory becomes screen transitions as-is.** With the same mental model as Next.js App Router, **the same routing** works for iOS / Android / Web. The essential value is that all screens automatically become **deep-link-capable.**

### 2-1. File structure and naming conventions (the 5 to learn first)

```text
app/
├─ _layout.tsx          # ルートレイアウト（全画面の親）
├─ index.tsx            # "/" 画面
├─ (tabs)/              # () = URLに出ないグループ化フォルダ
│  ├─ _layout.tsx       # タブバーの定義
│  ├─ index.tsx         # "/" タブ（ホーム）
│  └─ games.tsx         # "/games" タブ
├─ games/
│  └─ [gameId].tsx      # "/games/:gameId" 動的ルート
├─ +not-found.tsx       # 404画面
└─ api/
   └─ health+api.ts     # "/api/health" APIルート（サーバー）
```

The only 5 notations to learn are these.

- `_layout.tsx` … the **common frame** of that level (navigator, providers)
- `[param].tsx` … a **dynamic segment** (`[gameId]` → obtained with `useLocalSearchParams`)
- `(group)/` … grouping that **doesn't appear in the URL** (tabs / by authentication state, etc.)
- `+not-found.tsx` … the fallback when nothing matches
- `name+api.ts` … an **API route** (an endpoint at Web/server output)

### 2-2. The root layout: place providers here once

`app/_layout.tsx` is the parent of all screens. **Place Providers like authentication, theme, and the data client here once.** Writing them in each screen is a typical anti-pattern.

```tsx
// app/_layout.tsx
import { Stack } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useReactQueryDevTools } from '@dev-plugins/react-query';

const queryClient = new QueryClient();

export default function RootLayout() {
  useReactQueryDevTools(queryClient);
  return (
    <QueryClientProvider client={queryClient}>
      <Stack screenOptions={{ headerShown: false }}>
        {/* (tabs) グループはタブ自身がヘッダを持つので隠す */}
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
        <Stack.Screen
          name="games/[gameId]"
          options={{ presentation: 'modal', title: '試合詳細' }}
        />
      </Stack>
    </QueryClientProvider>
  );
}
```

### 2-3. Distinguishing tabs and stacks

The judgment is simple. **Going back and forth side by side = Tabs, diving deeper = Stack.** In real operation, this combination (pushing to a detail inside a tab) is the majority.

```tsx
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Home, ListChecks } from 'lucide-react-native';

export default function TabLayout() {
  return (
    <Tabs screenOptions={{ tabBarActiveTintColor: '#2563eb' }}>
      <Tabs.Screen
        name="index"
        options={{
          title: 'ホーム',
          // a11y: スクリーンリーダ向けにアイコンの意味を言語化
          tabBarIcon: ({ color, size }) => <Home color={color} size={size} />,
          tabBarAccessibilityLabel: 'ホームタブ',
        }}
      />
      <Tabs.Screen
        name="games"
        options={{
          title: '試合',
          tabBarIcon: ({ color, size }) => <ListChecks color={color} size={size} />,
          tabBarAccessibilityLabel: '試合一覧タブ',
        }}
      />
    </Tabs>
  );
}
```

### 2-4. Dynamic routes and type-safe transitions

Put in `experiments.typedRoutes` (enabled in §1) and `Link`'s `href` and `router.push` are **type-checked.** A nonexistent path or a missing required param fails at compile — a quietly effective safety device in a production app.

```tsx
// app/games/[gameId].tsx
import { useLocalSearchParams, Link, router } from 'expo-router';
import { View, Text, Pressable } from 'react-native';

export default function GameScreen() {
  // 型引数で params の型を固定。境界で型を絞るのが鉄則
  const { gameId } = useLocalSearchParams<{ gameId: string }>();

  return (
    <View>
      <Text accessibilityRole="header">試合 #{gameId}</Text>

      {/* 宣言的遷移：typedRoutesにより存在しないパスは型エラー */}
      <Link href={`/games/${gameId}/score`} accessibilityRole="link">
        スコア入力へ
      </Link>

      {/* 命令的遷移：イベントハンドラ内ではこちら */}
      <Pressable
        accessibilityRole="button"
        accessibilityLabel="一覧に戻る"
        onPress={() => router.back()}
      >
        <Text>戻る</Text>
      </Pressable>
    </View>
  );
}
```

### 2-5. API routes: co-locate light processing with the app

Expo Router lets you write **server-side endpoints with the `+api.ts` suffix** (at Web/server output; deployable to EAS Hosting as-is). It suits a webhook receiver, or **signing processing where you can't embed a secret key in the client.**

```ts
// app/api/health+api.ts
export function GET(request: Request) {
  return Response.json({ ok: true, ts: Date.now() });
}

// app/api/notify+api.ts — クライアントに鍵を出さずサーバーで処理
export async function POST(request: Request) {
  const body = await request.json();
  // process.env.* はサーバー側でのみ参照可能（バンドルに埋まらない）
  const res = await fetch('https://api.example.com/push', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.PUSH_API_KEY}` },
    body: JSON.stringify(body),
  });
  return Response.json({ delivered: res.ok }, { status: res.ok ? 200 : 502 });
}
```

> **How to draw the boundary**: heavy business logic and RLS-premised data access, in my case, go in Supabase / a dedicated backend. It's clean SRP-wise to limit `+api.ts` to "thin processing tightly coupled with the app but requiring a secret."

---

## 3. CNG and config plugins: express native changes with "settings"

"This library says to add a permission string to `Info.plist`" — in the CNG world, you don't open `ios/` by hand. **Inject it declaratively with a config plugin.**

### 3-1. First, get it done with official plugins

Many permission strings and build settings are resolved with an existing plugin's parameters. **Looking for this first** comes before hand-writing.

```ts
// app.config.ts（抜粋）
plugins: [
  [
    'expo-build-properties',
    {
      ios: { deploymentTarget: '15.1', useFrameworks: 'static' },
      android: { compileSdkVersion: 35, minSdkVersion: 24 },
    },
  ],
  [
    'expo-location',
    { locationWhenInUsePermission: '球場の位置から最寄りの試合を表示します。' },
  ],
],
```

### 3-2. A self-made config plugin (TypeScript, type-safe)

For adjustments not in the official ones, rewrite `AndroidManifest.xml` or `Info.plist` **programmatically** with a self-made plugin. In SDK 56, **config plugins can be written type-safely in TypeScript.**

```ts
// plugins/withClearTextDisabled.ts
// 目的：Androidの平文HTTP通信を本番で禁止する（セキュリティ既定の堅牢化）
import { type ConfigPlugin, withAndroidManifest } from 'expo/config-plugins';

const withClearTextDisabled: ConfigPlugin = (config) =>
  withAndroidManifest(config, (cfg) => {
    const app = cfg.modResults.manifest.application?.[0];
    if (app?.$) {
      app.$['android:usesCleartextTraffic'] = 'false';
    }
    return cfg;
  });

export default withClearTextDisabled;
```

```ts
// app.config.ts で読み込む
import withClearTextDisabled from './plugins/withClearTextDisabled';
// plugins 配列に: ...(他のplugin), './plugins/withClearTextDisabled.ts'
// もしくは関数を直接 import して plugins: [withClearTextDisabled]
```

CNG's joy is also in the point that "**no orphan code is born.**" Uninstall the package and erase the plugin declaration, and the corresponding native change also disappears on regeneration. "A mysterious native setting that somehow remained" can't occur in principle.

> **When to abandon CNG and go bare workflow**: when partially introducing into an existing huge native app (SDK 56's `expo-brownfield` is a candidate), or when you want to rapidly iterate on a deep native experiment that CNG can't express. Conversely, **it's rare for a new app to fall under this.** You should start with CNG.

---

## 4. EAS Build / Submit: production builds even without Xcode locally

**EAS Build** builds and signs iOS/Android in the cloud. You can build iOS without a Mac, and you can entrust the signing certificates to EAS too. Settings are consolidated in the **build profiles** of `eas.json`.

```jsonc
// eas.json
{
  "cli": { "version": ">= 16.0.0", "appVersionSource": "remote" },
  "build": {
    // 開発：実機で動くdev client（Fast Refresh付き）
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    // 社内配布：ストアを通さずQA・関係者に配る
    "preview": {
      "distribution": "internal",
      "channel": "preview"          // ← §5のOTAチャンネルと対応
    },
    // 本番：ストア提出用。ビルド番号を自動インクリメント
    "production": {
      "channel": "production",
      "autoIncrement": true
    }
  },
  "submit": { "production": {} }
}
```

Distinguishing the 3 profiles is the crux of operation.

| Profile | Distribution form | When to use |
| --- | --- | --- |
| `development` | dev client (internal) | Daily development. Hot reload on a real device |
| `preview` | internal | QA / client review. Share **without waiting for TestFlight / store review** |
| `production` | Store | Release production |

```bash
# 開発ビルド（一度入れれば、以降のJS変更はMetroから即反映）
eas build --profile development --platform ios

# 社内配布（QRやリンクでインストール）
eas build --profile preview --platform all

# 本番ビルド → そのままストア提出
eas build --profile production --platform all
eas submit --profile production --platform ios
```

**EAS Submit** uploads the finished binary to App Store Connect / Google Play via the CLI. The handling of certificates and API keys is automated, and the time of "touching the browser endlessly just to submit" disappears.

---

## 5. EAS Update (OTA): get this wrong and production breaks

This is the **most important** chapter in this article. **EAS Update can deliver JS / asset updates to users without going through store review.** You can deliver a typo fix or a logic hotfix to all devices in minutes — powerful, but **get "what you can deliver via OTA and what you can't" wrong and you mass-produce un-launchable apps.**

### 5-1. The golden rule: what you can send via OTA is "only JS and assets"

```text
✅ OTAで配信できる（= JSバンドルとアセット）
   ・Reactコンポーネント / 画面ロジックの変更
   ・文言・スタイルの修正、画像差し替え
   ・JSだけで完結するバグfix

❌ OTAで配信できない（= 新しいネイティブビルドが必須）
   ・新しいネイティブモジュールの追加（例：expo-camera を新規導入）
   ・Expo SDK のアップグレード
   ・app.config.ts のネイティブに影響する変更（権限・scheme等）
```

Why. What OTA swaps is only the in-device JS bundle, and **it doesn't touch the native binary.** Deliver JS that calls a new native module to an old binary that doesn't include it, and it **immediately crashes at runtime with "module not found."**

### 5-2. The mechanism to prevent accidents: runtimeVersion

The mechanism by which the framework prevents this accident is **`runtimeVersion`.** **An OTA update is delivered only to a build with the same `runtimeVersion` as itself.** Change runtimeVersion when native changes — do this manually and you'll definitely forget, so use the **`fingerprint` policy.**

```ts
// app.config.ts（抜粋）
export default (): ExpoConfig => ({
  // ...
  runtimeVersion: { policy: 'fingerprint' }, // ★推奨
  updates: { url: 'https://u.expo.dev/<your-project-id>' },
});
```

The `fingerprint` policy **hashes the native-layer configuration (dependencies, settings, the result of config plugins)** and auto-computes runtimeVersion. That is:

- Changed only JS → the fingerprint is the same → **OTA arrives** (correct)
- Changed native → the fingerprint changes → **OTA doesn't arrive at the old build** (= preventing the crash)

The worst case of "carelessly delivering a native change via OTA and all devices crashing" structurally stops happening with a single setting.

### 5-3. Channels and branches: controlling the delivery target

- **Channel**: a logical group of delivery-target builds. Specified in the `eas.json` profile (`preview` / `production`).
- **Branch**: a series of update history tied to a channel. A concept similar to a git branch.

```bash
# preview ビルド群へ更新を配信（QA確認用）
eas update --channel preview --message "スコア表示の桁ズレ修正"

# 本番へ配信
eas update --channel production --message "hotfix: 打順入力のクラッシュ修正"
```

You can naturally assemble a **promotion flow** of verify on `preview` → if no problem, `production`. This is the substance of changing "days of waiting for review" to "minutes."

### 5-4. Resilience: immediate rollback from a broken update

OTA is fast, so a broken update also spreads fast. The production practice is to **always have a rollback means.** You can immediately revert to the previous healthy update.

```bash
# 不正な更新を無効化し、直前の正常版へ戻す
eas update:rollback --channel production
```

In addition, control the fetching of updates on the app side, and **observe, not swallow, failure.**

```tsx
// 起動時に最新更新を取りに行く（既定はバックグラウンド取得→次回起動で反映）
import * as Updates from 'expo-updates';

export async function syncUpdates() {
  if (__DEV__) return; // 開発中はスキップ
  try {
    const result = await Updates.checkForUpdateAsync();
    if (result.isAvailable) {
      await Updates.fetchUpdateAsync();
      await Updates.reloadAsync(); // 重要更新は即時適用
    }
  } catch (e) {
    // 失敗を黙殺しない：監視基盤へ送る（Sentry等）
    reportError('eas-update-sync-failed', e);
  }
}
```

---

## 6. EAS Workflows: bundle build-to-distribution in CI

**EAS Workflows** declaratively chains build, update, and submit as **cloud CI/CD jobs** (YAML under `.eas/workflows/`). You can automate "OTA to preview on merge to main, production build on a tag."

```yaml
# .eas/workflows/deploy-preview.yml
name: Deploy Preview on merge
on:
  push:
    branches: ['main']
jobs:
  publish_preview_update:
    name: Publish OTA to preview
    type: update
    params:
      channel: preview
      message: 'auto: merged to main'
```

```yaml
# .eas/workflows/release.yml — タグで本番ビルド＆提出
name: Production release
on:
  push:
    tags: ['v*']
jobs:
  build:
    type: build
    params: { platform: all, profile: production }
  submit:
    needs: [build]
    type: submit
    params: { platform: all, profile: production }
```

From local, manual launch with `eas workflow:run deploy-preview.yml` is also possible. **Even in solo development**, you can eliminate the state of "a human remembering the command," and person-dependence disappears.

---

## 7. Production-operation pressure points (observability, resilience, idempotency, security, a11y)

Up to here was "Expo's features." Finally, let me summarize **the cross-cutting design for withstanding production**, in the order it paid off in real operation.

### 7-1. Security: don't bake secrets into the bundle

The most frequent accident is **mixing secret information into the bundle.** There are 2 iron rules.

- **Environment variables with the `EXPO_PUBLIC_` prefix are baked into the JS bundle as plaintext.** It's exclusively for values OK to publish (a public API's base URL, etc.). **Never put API keys / secrets in.**
- Sensitive information like a user's token is stored in **`expo-secure-store`** (iOS Keychain / Android Keystore). `AsyncStorage` is unencrypted and unsuitable.

```ts
import * as SecureStore from 'expo-secure-store';

// OSの安全領域に保存（端末暗号化ストレージ）
await SecureStore.setItemAsync('session_token', token, {
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
const token = await SecureStore.getItemAsync('session_token');
```

> Server processing requiring a secret goes in §2-5's `+api.ts` or a dedicated backend. **"Don't trust the client"** is the consistent backbone of my mobile design (the same intent is detailed in [this article](/blog/untrusted-client-postgres-rls-offline-first)).

### 7-2. Observability: see production crashes and OTA success/failure

Always measure `expo-updates`'s state (which update ID is in effect) and crashes. Being able to match "which update it crashed on" with `Updates.updateId` after an OTA delivery makes cause isolation instant. To error monitoring (Sentry, etc.), you can **auto-upload source maps from the EAS build**, so you can read even a minified stack.

### 7-3. Idempotency / resilience: write mobile "on the premise it disconnects"

In an environment with poor reception, requests **duplicate, get out of order, and fail.** On this premise, absorb duplicates of writes with **a deterministic idempotency key**, and make it **offline-first** with a local persistent queue → background sending. The concrete implementation of this (the AsyncStorage persistent queue and a single drain worker, leaning to Supabase RLS) is written in a [separate article with real code](/blog/untrusted-client-postgres-rls-offline-first). This layer rides on top of this article's Expo foundation.

### 7-4. Performance: the New Architecture and Hermes are now default

In SDK 56, the **New Architecture (can't be disabled)** and **Hermes v1** (the default engine) are standard. Without special settings, startup time and the over-the-bridge overhead are improved. What you should do on the app side is the royal road — virtualize lists with `FlatList` / `FlashList`, heavy computation with `useMemo`, and images with `expo-image` (with disk caching and priority control).

### 7-5. a11y: declaratively attach roles and labels

As touched on in §2's code, attach `accessibilityRole` / `accessibilityLabel` **at the point of creating the element.** Retrofitting leaks. Touch areas at least 44×44pt, keep the focus order and contrast — weave this into the design as **a premise from the start**, not "compliance."

---

## 8. When to choose Expo and when to avoid it (an honest decision axis)

To not lie in technology selection, let me write the limits too.

**Cases where Expo is optimal:**

- You want to ship iOS / Android / Web simultaneously **with a small team**
- An operation of **fixing quickly** without waiting for store review (OTA)
- You want to manage native declaratively and **lower the cost of SDK upgrades**

**Cases to consider carefully:**

- An **ultra-low-level native feature** or a niche SDK integration is the core, and even a config plugin can't absorb it (→ bare/brownfield, or plain native)
- An app premised on a **dedicated engine** like a game
- There's an extreme constraint on binary size (it grows by the Expo runtime's amount)

My real project fell entirely under "small team, multi-platform, fast operational updates," so Expo was the best choice. **Conversely, if it doesn't apply, I don't force-fit it** — this is a matter of the honesty of technology selection.

---

## Summary: Expo is a production foundation that's "fast and safe to operate"

Expo in 2026 is a consistent operational foundation of **making the settings (app.config.ts) the truth, regenerating native with CNG, building and distributing with EAS, and zero-downtime updating with OTA.** The key points in five lines at the end.

1. **`app.config.ts` is the single truth.** `android/`/`ios/` are artifacts. Manage declaratively with CNG + config plugins.
2. **Expo Router v7** is file structure = screen transitions. Make transitions type-safe with `typedRoutes`.
3. **EAS Build/Submit** for production builds without a Mac and store submission. Pre-review sharing with the `preview` profile.
4. **EAS Update (OTA)** is powerful, but what you can deliver is **only JS/assets.** Structurally prevent accidents with `runtimeVersion: { policy: 'fingerprint' }`. Always have rollback.
5. **Don't bake secrets into the bundle** (the `EXPO_PUBLIC_` trap, `expo-secure-store`). Weave idempotency, offline resilience, and observability into the design from the start.

"With one person × generative AI (Claude Code), building fast, cheap, and safe" — building end to end from mobile to a Web admin panel, DB, and CI/CD — its real example is the [real-time game-scoring app](/case-studies/realtime-sports-scoring-app), the source of this article's code. For consultation on app development and production-operation design with Expo / React Native, reach out from [contact](/contact).
