"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 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), 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.
// 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)
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 withuseLocalSearchParams)(group)/… grouping that doesn't appear in the URL (tabs / by authentication state, etc.)+not-found.tsx… the fallback when nothing matchesname+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.
// 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.
// 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.
// 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.
// 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.tsto "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.
// 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.
// 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'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-brownfieldis 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.
// 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 |
# 開発ビルド(一度入れれば、以降の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"
✅ 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.
// 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.jsonprofile (preview/production). - Branch: a series of update history tied to a channel. A concept similar to a git branch.
# 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.
# 不正な更新を無効化し、直前の正常版へ戻す
eas update:rollback --channel production
In addition, control the fetching of updates on the app side, and observe, not swallow, failure.
// 起動時に最新更新を取りに行く(既定はバックグラウンド取得→次回起動で反映)
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."
# .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 }
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).AsyncStorageis unencrypted and unsuitable.
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.tsor a dedicated backend. "Don't trust the client" is the consistent backbone of my mobile design (the same intent is detailed in this article).
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. 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.
app.config.tsis the single truth.android//ios/are artifacts. Manage declaratively with CNG + config plugins.- Expo Router v7 is file structure = screen transitions. Make transitions type-safe with
typedRoutes. - EAS Build/Submit for production builds without a Mac and store submission. Pre-review sharing with the
previewprofile. - EAS Update (OTA) is powerful, but what you can deliver is only JS/assets. Structurally prevent accidents with
runtimeVersion: { policy: 'fingerprint' }. Always have rollback. - 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, the source of this article's code. For consultation on app development and production-operation design with Expo / React Native, reach out from contact.