「Expoはプロトタイプ用で、本気のアプリでは結局 eject する」——これはもう古い認識です。2026年のExpoは、react-native init を置き換える公式推奨のフレームワークになりました。私自身、アマチュア野球向けのリアルタイム試合記録アプリ(iOS / Android のモバイル + Web管理画面)を Expo + Next.js + Supabase のモノレポで一人で作り、ストア配信から OTA 更新まで運用しています。
この記事は、その実運用で得た「どの機能を・いつ・どう使うか」を、Expo公式ドキュメント(SDK 56 時点)に忠実に、しかし公式より判断軸を厚くして解説するものです。コードはすべて、実際に手を動かせる粒度で載せます。
本記事の基準バージョン:Expo SDK 56(React Native 0.85 / React 19.2、Hermes v1 がデフォルトエンジン)。SDK 55 以降、Legacy Architecture は廃止され New Architecture が常時有効(無効化不可)です。Expo Router は v7 に到達し、React Navigation から独立しました。
0. 全体像:Expoの「4つの柱」をどう組み合わせるか
Expoを本番で使うとは、実質この4つを設計することです。混同すると事故ります。
| 柱 | 役割 | 中心ツール | この記事の章 |
|---|---|---|---|
| 画面遷移 | ルーティング・ディープリンク | Expo Router v7 | §2 |
| ネイティブ層 | iOS/Androidプロジェクト生成 | CNG / prebuild / config plugin | §3 |
| ビルド・配信 | クラウドビルドとストア提出 | EAS Build / Submit | §4 |
| 無停止更新 | 審査を通さないJS更新 | EAS Update(OTA) | §5 |
順番に意味があります。Router でアプリの形を決め、CNG でネイティブを宣言的に管理し、EAS でビルドし、Update で運用中に直す。以下、この流れで進めます。
1. メンタルモデル:app.config.ts が唯一の真実
最初に頭を切り替えるべき点はここです。android/ と ios/ ディレクトリは「成果物」であって「ソース」ではない、という発想。
従来のReact Nativeは、android/ ios/ を一度作って手で育てます。ExpoのCNG(Continuous Native Generation / 継続的ネイティブ生成) では、ネイティブプロジェクトを設定から毎回生成し直す。生成元は app.config.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で使う型安全ルーティング
});
これがもたらす実利は、SDKアップグレードが「他人が書いたネイティブコードの差分を読む作業」から「package.jsonのバージョンを上げて再生成するだけ」に変わること。npx expo prebuild --clean で android/ ios/ を捨てて作り直せます。
判断軸:
android//ios/を git にコミットすべきか? CNGを使うならコミットしない(.gitignoreに入れる)のが原則です。生成物をコミットすると「設定 vs 生成物」の二重管理が始まり、CNGの利点が消えます。手書きのネイティブ変更が必要になったら、§3の config plugin で設定側に寄せるのが正解です。
2. Expo Router v7:ファイルベース・ルーティングを正しく使う
Expo Router は app/ ディレクトリのファイル構造がそのまま画面遷移になる仕組みです。Next.js App Router と同じメンタルモデルで、iOS / Android / Web に同一のルーティングが効きます。すべての画面が自動でディープリンク対応になるのが本質的な価値です。
2-1. ファイル構造と命名規則(最初に覚える5つ)
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ルート(サーバー)
覚えるのはこの5記法だけです。
_layout.tsx… その階層の共通の枠(ナビゲータ、プロバイダ)[param].tsx… 動的セグメント([gameId]→useLocalSearchParamsで取得)(group)/… URLに現れないグループ化(タブ/認証状態別など)+not-found.tsx… マッチしなかった時のフォールバックname+api.ts… APIルート(Web/サーバー出力時のエンドポイント)
2-2. ルートレイアウト:プロバイダはここに一度だけ置く
app/_layout.tsx は全画面の親です。認証・テーマ・データクライアントなどの Provider は、ここに一度だけ置きます。各画面に書くのは典型的なアンチパターンです。
// 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. タブとスタックの使い分け
判断はシンプルです。横並びで行き来する=Tabs、奥に潜る=Stack。実運用ではこの組み合わせ(タブの中で詳細にプッシュ)が大半です。
// 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. 動的ルートと型安全な遷移
experiments.typedRoutes(§1で有効化)を入れると、Link の href と router.push が型チェックされます。存在しないパスや必須paramの抜けがコンパイルで落ちる——本番アプリで地味に効く安全装置です。
// 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ルート:軽い処理はアプリと同居させる
Expo Router は +api.ts サフィックスでサーバーサイドのエンドポイントを書けます(Web/サーバー出力時。EAS Hosting にそのままデプロイ可)。Webhook受け口や、クライアントに秘密鍵を埋め込めない署名処理を置くのに向きます。
// 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 });
}
境界線の引き方:重いビジネスロジック・RLS前提のデータアクセスは、私の場合 Supabase / 専用バックエンドに置きます。
+api.tsは「アプリと密結合だが秘密を要する薄い処理」に限定するのがSRP的にきれいです。
3. CNG と config plugin:ネイティブ変更を「設定」で表現する
「このライブラリは Info.plist に権限文言を足せと言っている」——CNGの世界では、ios/ を手で開きません。config plugin で宣言的に注入します。
3-1. まず公式プラグインで済ませる
権限文言やビルド設定の多くは、既存プラグインのパラメータで解決します。手書きする前にこれを探すのが先です。
// app.config.ts(抜粋)
plugins: [
[
'expo-build-properties',
{
ios: { deploymentTarget: '15.1', useFrameworks: 'static' },
android: { compileSdkVersion: 35, minSdkVersion: 24 },
},
],
[
'expo-location',
{ locationWhenInUsePermission: '球場の位置から最寄りの試合を表示します。' },
],
],
3-2. 自作 config plugin(TypeScript・型安全)
公式にない調整は、自作プラグインで AndroidManifest.xml や Info.plist をプログラムで書き換えます。SDK 56 ではconfig pluginがTypeScriptで型安全に書けるようになりました。
// 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;
// app.config.ts で読み込む
import withClearTextDisabled from './plugins/withClearTextDisabled';
// plugins 配列に: ...(他のplugin), './plugins/withClearTextDisabled.ts'
// もしくは関数を直接 import して plugins: [withClearTextDisabled]
CNGの嬉しさは「孤児コードが生まれない」点にもあります。パッケージをアンインストールしてプラグイン宣言を消すと、再生成時に対応するネイティブ変更も消える。「なぜか残った謎のネイティブ設定」が原理的に発生しません。
CNGを捨てて bare workflow にすべき時:既存の巨大ネイティブアプリへ部分導入する(SDK 56の
expo-brownfieldが候補)、あるいはCNGで表現できない深いネイティブ実験を高速で回したい時。逆に言えば、新規アプリでこれに当てはまることは稀です。まずCNGで始めるべきです。
4. EAS Build / Submit:ローカルにXcodeが無くても本番ビルド
EAS Build はクラウドでiOS/Androidをビルド・署名します。Mac不要でiOSがビルドでき、署名証明書もEASに管理を任せられます。設定は eas.json のビルドプロファイルに集約します。
// 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": {} }
}
3プロファイルの使い分けが運用の肝です。
| プロファイル | 配布形態 | いつ使うか |
|---|---|---|
development | dev client(内部) | 日々の開発。実機でホットリロード |
preview | internal | QA・クライアントレビュー。TestFlight/ストア審査を待たずに共有 |
production | ストア | リリース本番 |
# 開発ビルド(一度入れれば、以降の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 は出来上がったバイナリを App Store Connect / Google Play へ CLI でアップロードします。証明書・API キーの取り回しが自動化され、「提出のためだけにブラウザを延々と触る」時間が消えます。
5. EAS Update(OTA):ここを間違えると本番が壊れる
ここがこの記事で最も重要な章です。EAS Update は、ストア審査を通さずにJS・アセットの更新をユーザーへ配信できます。タイポ修正やロジックのホットフィックスを、数分で全端末に届けられる——強力ですが、「何をOTAで配れて、何は配れないか」を誤ると、起動不能のアプリを量産します。
5-1. 黄金律:OTAで送れるのは「JSとアセットだけ」
✅ OTAで配信できる(= JSバンドルとアセット)
・Reactコンポーネント / 画面ロジックの変更
・文言・スタイルの修正、画像差し替え
・JSだけで完結するバグfix
❌ OTAで配信できない(= 新しいネイティブビルドが必須)
・新しいネイティブモジュールの追加(例:expo-camera を新規導入)
・Expo SDK のアップグレード
・app.config.ts のネイティブに影響する変更(権限・scheme等)
なぜか。OTAが差し替えるのは端末内のJSバンドルだけで、ネイティブバイナリには触れないから。新しいネイティブモジュールを呼ぶJSを、それを含まない古いバイナリへ配ると、実行時に「モジュールが無い」で即クラッシュします。
5-2. 事故を防ぐ仕組み:runtimeVersion
この事故をフレームワークが防ぐ仕組みが runtimeVersion です。OTA更新は、自分と同じ runtimeVersion を持つビルドにしか配信されません。ネイティブが変われば runtimeVersion を変える——これを手動でやると必ず忘れるので、fingerprint ポリシーを使います。
// app.config.ts(抜粋)
export default (): ExpoConfig => ({
// ...
runtimeVersion: { policy: 'fingerprint' }, // ★推奨
updates: { url: 'https://u.expo.dev/<your-project-id>' },
});
fingerprint ポリシーは、ネイティブ層の構成(依存・設定・config plugin の結果)をハッシュ化して runtimeVersion を自動算出します。つまり:
- JSだけ変えた → fingerprint は同じ → OTAが届く(正しい)
- ネイティブを変えた → fingerprint が変わる → 古いビルドにはOTAが届かない(=クラッシュを未然に防止)
「うっかりネイティブ変更をOTAで配って全端末が落ちる」という最悪が、設定ひとつで構造的に起きなくなります。
5-3. チャンネルとブランチ:配信先の制御
- チャンネル(channel):配信先ビルドの論理グループ。
eas.jsonのプロファイルで指定(preview/production)。 - ブランチ(branch):チャンネルに紐づく更新履歴の系列。git のブランチに似た概念。
# preview ビルド群へ更新を配信(QA確認用)
eas update --channel preview --message "スコア表示の桁ズレ修正"
# 本番へ配信
eas update --channel production --message "hotfix: 打順入力のクラッシュ修正"
preview で検証 → 問題なければ production、という昇格フローが自然に組めます。これが「審査待ち数日」を「数分」に変える実体です。
5-4. 回復性:壊れた更新からの即時ロールバック
OTAは速い分、壊れた更新も速く広がります。ロールバック手段を常備するのが本番の作法です。直前の正常な更新へ即座に戻せます。
# 不正な更新を無効化し、直前の正常版へ戻す
eas update:rollback --channel production
加えて、アプリ側で更新の取得を制御し、失敗時に握り潰さず観測します。
// 起動時に最新更新を取りに行く(既定はバックグラウンド取得→次回起動で反映)
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:ビルド〜配信をCIで束ねる
EAS Workflows は、ビルド・更新・提出をクラウドのCI/CDジョブとして宣言的に連結します(.eas/workflows/ 配下のYAML)。「mainにマージしたらpreviewへOTA、タグを打ったら本番ビルド」を自動化できます。
# .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'
# .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 }
ローカルからは eas workflow:run deploy-preview.yml で手動起動も可能。個人開発でも「人間がコマンドを覚えている」状態を排除でき、属人性が消えます。
7. 本番運用の勘所(可観測性・回復性・冪等性・セキュリティ・a11y)
ここまでが「Expoの機能」。最後に、本番に耐えるための横断的な設計を、実運用で効いた順にまとめます。
7-1. セキュリティ:秘密はバンドルに埋めない
最頻出の事故が秘密情報のバンドル混入です。鉄則は2つ。
EXPO_PUBLIC_接頭辞の環境変数は、JSバンドルに平文で焼き込まれます。 公開して構わない値(公開APIのベースURL等)専用です。APIキー・シークレットを絶対に入れない。- ユーザーのトークン等の機微情報は
expo-secure-store(iOS Keychain / Android Keystore)に保存します。AsyncStorageは暗号化されないため不適です。
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');
秘密を要するサーバー処理は、§2-5の
+api.tsか専用バックエンドに置く。**「クライアントを信じない」**は、私のモバイル設計の一貫した背骨です(同主旨をこちらの記事で詳述)。
7-2. 可観測性:本番のクラッシュとOTA成否を見る
expo-updates の状態(どのupdate IDが効いているか)と、クラッシュを必ず計測します。OTA配信後に「どの更新で落ちたか」を Updates.updateId で突き合わせられると、原因切り分けが一瞬になります。エラー監視(Sentry等)にはソースマップをEASビルドから自動アップロードできるため、ミニファイ後のスタックでも読めます。
7-3. 冪等性・回復性:モバイルは「切れる前提」で書く
電波の悪い環境では、リクエストは重複し・順序が乱れ・失敗します。これを前提に、書き込みは決定的な冪等性キーで重複吸収し、ローカル永続キュー → バックグラウンド送信でオフラインファーストにする。この具体実装(AsyncStorageの永続キューと単一ドレインワーカー、Supabase RLSへの寄せ)は、実コード付きの別記事に書きました。本記事のExpo土台の上に、この層が乗ります。
7-4. パフォーマンス:New ArchitectureとHermesはもうデフォルト
SDK 56 では New Architecture(無効化不可) と Hermes v1(デフォルトエンジン)が標準です。特別な設定なしに起動時間とブリッジ越しのオーバーヘッドが改善されています。アプリ側でやるべきは王道——リストは FlatList / FlashList で仮想化、重い計算は useMemo、画像は expo-image(ディスクキャッシュ・優先度制御つき)を使う、です。
7-5. a11y:宣言的に役割とラベルを付ける
§2のコードで触れた通り、accessibilityRole / accessibilityLabel を要素を作る時点で付けます。後付けは漏れます。タッチ領域は最低44×44pt、フォーカス順序とコントラストを守る——これは「対応」ではなく最初からの前提として設計に織り込みます。
8. いつExpoを選び、いつ避けるか(正直な判断軸)
技術選定で嘘をつかないために、限界も書きます。
Expoが最適なケース:
- iOS / Android / Web を少人数で同時に出したい
- ストア審査を待たずに素早く直したい運用(OTA)
- ネイティブを宣言的に管理し、SDKアップグレードのコストを下げたい
慎重に検討すべきケース:
- 超低レベルなネイティブ機能やニッチなSDK連携が中核で、config pluginでも吸収しきれない(→ bare/brownfield、あるいは素のネイティブ)
- ゲームのような専用エンジン前提のアプリ
- バイナリサイズに極端な制約がある(Expoランタイムのぶん増える)
私の実プロジェクトは「少人数・マルチプラットフォーム・高速な運用更新」に全部当てはまったため、Expoは最良の選択でした。逆に当てはまらないなら、無理に寄せません——これは技術選定の誠実さの問題です。
まとめ:Expoは「速くて安全に運用できる」本番基盤
2026年のExpoは、設定(app.config.ts)を真実とし、CNGでネイティブを再生成し、EASでビルド・配信し、OTAで無停止更新する——という一貫した運用基盤です。要点を最後に5行で。
app.config.tsが唯一の真実。android//ios/は生成物。CNG + config plugin で宣言的に管理する。- Expo Router v7 はファイル構造=画面遷移。
typedRoutesで遷移を型安全に。 - EAS Build/Submit でMac不要の本番ビルドとストア提出。
previewプロファイルで審査前共有。 - EAS Update(OTA) は強力だが、配れるのはJS/アセットだけ。
runtimeVersion: { policy: 'fingerprint' }で事故を構造的に防ぐ。ロールバックを常備。 - 秘密はバンドルに埋めない(
EXPO_PUBLIC_の罠、expo-secure-store)。冪等性・オフライン耐性・可観測性は最初から設計に織り込む。
「一人 × 生成AI(Claude Code)で、速く・安く・安全に」モバイルからWeb管理画面・DB・CI/CDまで一気通貫で作る——その実例が、本記事のコードの出どころであるリアルタイム試合記録アプリです。Expo / React Native でのアプリ開発・本番運用設計のご相談は、お問い合わせからどうぞ。