$ cat post.metadata

1つのExpoコードベースから複数アプリをEASでデプロイする設計パターン

React NativeMobile

Expo SDK 54 + EAS Build環境で、1つのコードベースから複数のアプリをプロジェクトごとに異なる設定でビルド・デプロイする設計パターン。app.config.tsによる動的設定切替、デプロイスクリプト、GitHub Actions並列ビルドまで。

$ cat post.content | render --format=markdown

1つのExpoコードベースから複数アプリをEASでデプロイする設計パターン

TL;DR

projects/ ディレクトリにプロジェクト別の app.json / eas.json / .env / アセットを配置し、app.config.tsAPP_PROJECT 環境変数で動的に読み込む。EASの「eas.json はルート固定」制約はデプロイスクリプトでのコピーで回避する。共通コードに一切触れずにプロジェクトを追加でき、一斉デプロイ・選択デプロイの両方に対応できる。

背景

React Native(Expo)で開発したアプリを、ブランド違いや用途違いで複数のアプリとしてストアに公開したいケースがある。

  • 同じ機能セットを持つホワイトラベルアプリ
  • 本体アプリとライト版
  • テナントごとのカスタムアプリ

「リポジトリをフォークして別管理」はコードの同期が地獄になる。かといってExpoの app.jsoneas.json はプロジェクトルートに1つしか置けない。

この制約の中で、1つのコードベースを維持したまま複数プロジェクトをビルド・デプロイする設計を考えた。

前提環境

  • Expo SDK 54
  • EAS CLI
  • React Native 0.81+
  • Bun(パッケージマネージャ)
  • monorepo構成(単体リポジトリでも適用可能)

課題の整理

EASの制約

EAS Build には以下の制約がある。

  1. eas.json はプロジェクトルート固定 — パスの指定ができない
  2. app.json(または app.config.ts)もルート固定 — Expo CLIが読み取る
  3. extra.eas.projectId でExpoダッシュボード上のプロジェクトが決まる

つまり、プロジェクトごとに app.jsoneas.json の中身を切り替える仕組みが必要になる。

プロジェクト間で「異なるもの」と「共通のもの」

設計の出発点として差分を洗い出す。

プロジェクトごとに異なる

ファイル差分内容
app.jsonname, slug, bundleIdentifier, package, projectId, scheme, icon, splash
eas.jsonsubmit設定(Apple ID, ASC App ID, Team ID, Google Play設定)
assets/アイコン・スプラッシュ画像
.env認証情報、API URL、Firebase設定

プロジェクト間で共通

ファイル内容
src/全画面・コンポーネント・hooks・サービス
metro.config.jsMetro Bundler設定
babel.config.jsBabel設定
tailwind.config.jsNativeWindテーマ
package.json依存関係
tsconfig.jsonTypeScript設定

共通部分のほうが圧倒的に多い。差分はほぼ「設定ファイルとアセット」に限定される。

設計

ディレクトリ構成

mobile/
├── projects/                    # プロジェクト別設定を集約
│   ├── app-alpha/
│   │   ├── app.json             # Alpha用のExpo設定
│   │   ├── eas.json             # Alpha用のEAS設定
│   │   ├── .env                 # Alpha用の環境変数
│   │   └── assets/
│   │       └── images/
│   │           ├── icon.png
│   │           ├── splash.png
│   │           └── adaptive-icon.png
│   └── app-beta/
│       ├── app.json
│       ├── eas.json
│       ├── .env
│       └── assets/
│           └── images/
│               ├── icon.png
│               ├── splash.png
│               └── adaptive-icon.png
│
├── app.config.ts                # 動的にプロジェクト設定を読み込む
├── scripts/
│   └── eas-deploy.sh            # マルチプロジェクトデプロイスクリプト
│
├── src/                         # 共通ソースコード(変更なし)
├── metro.config.js              # 共通(変更なし)
├── babel.config.js              # 共通(変更なし)
├── tailwind.config.js           # 共通(変更なし)
├── package.json                 # 共通(変更なし)
└── tsconfig.json                # 共通(変更なし)

projects/ に設定を閉じ込め、共通コードには一切手を加えない。

app.config.ts — 動的設定の読み込み

app.json を直接使う代わりに app.config.ts を作成し、環境変数 APP_PROJECT でプロジェクト設定を切り替える。

typescript
import type { ExpoConfig } from "expo/config"; import fs from "node:fs"; import path from "node:path"; const APP_PROJECT = process.env.APP_PROJECT; if (!APP_PROJECT) { throw new Error( "APP_PROJECT environment variable is required.\n" + "Usage: APP_PROJECT=app-alpha npx expo start" ); } const projectDir = path.resolve(__dirname, "projects", APP_PROJECT); if (!fs.existsSync(projectDir)) { const available = fs.readdirSync(path.resolve(__dirname, "projects")); throw new Error( `Project "${APP_PROJECT}" not found.\n` + `Available projects: ${available.join(", ")}` ); } // プロジェクト固有の app.json を読み込む const projectAppJson = JSON.parse( fs.readFileSync(path.join(projectDir, "app.json"), "utf-8") ); // アセットパスをプロジェクトディレクトリからの相対パスに変換 const assetDir = `./projects/${APP_PROJECT}/assets`; const config: ExpoConfig = { ...projectAppJson.expo, icon: `${assetDir}/images/icon.png`, splash: { ...projectAppJson.expo.splash, image: `${assetDir}/images/splash.png`, }, android: { ...projectAppJson.expo.android, adaptiveIcon: { ...projectAppJson.expo.android?.adaptiveIcon, foregroundImage: `${assetDir}/images/adaptive-icon.png`, }, }, }; export default (): ExpoConfig => config;

各プロジェクトの app.json は標準的なExpoフォーマットをそのまま使える。独自の設定ファイルフォーマットを定義する必要がない。

eas.json の切り替え

EAS CLIは eas.json のパスを変更できない。ビルド前にプロジェクト固有の eas.json をルートにコピーする方式で回避する。

デプロイスクリプト

bash
#!/bin/bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" MOBILE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECTS_DIR="$MOBILE_DIR/projects" usage() { cat <<EOF Usage: $(basename "$0") --all [eas-build-options...] $(basename "$0") --projects alpha,beta [eas-build-options...] $(basename "$0") --project alpha [eas-build-options...] Examples: $(basename "$0") --all --profile production --platform ios $(basename "$0") --projects alpha,beta --profile preview --platform all $(basename "$0") --project alpha --profile development --platform ios EOF exit 1 } get_available_projects() { ls -d "$PROJECTS_DIR"/*/ 2>/dev/null | xargs -I {} basename {} | sort } build_project() { local project="$1" shift local eas_args=("$@") local project_dir="$PROJECTS_DIR/$project" if [ ! -d "$project_dir" ]; then echo "Error: Project '$project' not found" echo "Available: $(get_available_projects | tr '\n' ' ')" exit 1 fi echo "=========================================" echo "Building: $project" echo "=========================================" # プロジェクト固有の設定をルートにコピー [ -f "$project_dir/eas.json" ] && cp "$project_dir/eas.json" "$MOBILE_DIR/eas.json" [ -f "$project_dir/.env" ] && cp "$project_dir/.env" "$MOBILE_DIR/.env" cd "$MOBILE_DIR" APP_PROJECT="$project" npx eas build "${eas_args[@]}" echo "" } # 引数パース PROJECTS=() EAS_ARGS=() while [[ $# -gt 0 ]]; do case "$1" in --all) mapfile -t PROJECTS < <(get_available_projects) shift ;; --projects) IFS=',' read -ra PROJECTS <<< "$2" shift 2 ;; --project) PROJECTS=("$2") shift 2 ;; --help|-h) usage ;; *) EAS_ARGS+=("$1") shift ;; esac done [ ${#PROJECTS[@]} -eq 0 ] && usage # 元のファイルをバックアップ → 終了時に復元 ORIGINAL_EAS=""; ORIGINAL_ENV="" [ -f "$MOBILE_DIR/eas.json" ] && ORIGINAL_EAS=$(cat "$MOBILE_DIR/eas.json") [ -f "$MOBILE_DIR/.env" ] && ORIGINAL_ENV=$(cat "$MOBILE_DIR/.env") cleanup() { [ -n "$ORIGINAL_EAS" ] && echo "$ORIGINAL_EAS" > "$MOBILE_DIR/eas.json" [ -n "$ORIGINAL_ENV" ] && echo "$ORIGINAL_ENV" > "$MOBILE_DIR/.env" } trap cleanup EXIT for project in "${PROJECTS[@]}"; do build_project "$project" "${EAS_ARGS[@]}" done echo "All builds submitted: ${PROJECTS[*]}"

trap cleanup EXIT で、スクリプトが異常終了しても元のファイルに復元される。

使い方

日常の開発

bash
# 特定プロジェクトで開発サーバーを起動 APP_PROJECT=app-alpha npx expo start

ビルド

bash
# 単一プロジェクト ./scripts/eas-deploy.sh --project app-alpha --profile production --platform ios # 複数プロジェクトを指定 ./scripts/eas-deploy.sh --projects app-alpha,app-beta --profile production --platform ios # 全プロジェクトに一斉リリース ./scripts/eas-deploy.sh --all --profile production --platform ios

package.json にスクリプトを追加

json
{ "scripts": { "build:alpha:dev": "APP_PROJECT=app-alpha npx eas build --profile development --platform ios", "build:alpha:prod": "APP_PROJECT=app-alpha npx eas build --profile production --platform all", "build:beta:prod": "APP_PROJECT=app-beta npx eas build --profile production --platform all", "build:all:prod": "./scripts/eas-deploy.sh --all --profile production --platform all", "start:alpha": "APP_PROJECT=app-alpha npx expo start", "start:beta": "APP_PROJECT=app-beta npx expo start" } }

新しいプロジェクトの追加手順

  1. Expoダッシュボードでプロジェクト作成 → projectId を控える
  2. mkdir -p projects/app-gamma/assets/images
  3. app.json, eas.json, .env を既存プロジェクトからコピーして編集
  4. アイコン・スプラッシュ画像を配置
  5. Apple Developer / Google Play Console でアプリ登録
  6. ./scripts/eas-deploy.sh --project app-gamma --profile development --platform ios

設定ファイル3つ + 画像を置くだけで完了する。

ios / android ネイティブプロジェクトの扱い

EAS Buildでは npx expo prebuild が毎回実行され、app.config.ts の設定に基づいてネイティブプロジェクトが生成される。

  • ios/, android/.gitignore に入れておく
  • bundleIdentifierpackage が変わっても、prebuildが正しいネイティブプロジェクトを生成する
  • Custom Native Modulesがある場合は Expo Config Plugins で管理する

ローカル開発で npx expo prebuild する場合はプロジェクト切替のたびにネイティブディレクトリが再生成されるので、npx expo prebuild --clean を使う。

CI/CD(GitHub Actions)

yaml
name: EAS Build on: workflow_dispatch: inputs: projects: description: 'Projects to build (comma-separated, or "all")' required: true default: 'all' profile: description: 'Build profile' required: true default: 'production' type: choice options: [development, preview, production] platform: description: 'Platform' required: true default: 'all' type: choice options: [ios, android, all] jobs: build: runs-on: ubuntu-latest strategy: matrix: project: ${{ fromJson( inputs.projects == 'all' && '["app-alpha","app-beta"]' || format('["{0}"]', inputs.projects) ) }} steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - uses: expo/expo-github-action@v8 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} - run: bun install - name: Copy project config run: | cp projects/${{ matrix.project }}/eas.json ./eas.json cp projects/${{ matrix.project }}/.env ./.env - name: Build run: > APP_PROJECT=${{ matrix.project }} npx eas build --profile ${{ inputs.profile }} --platform ${{ inputs.platform }} --non-interactive

matrix でプロジェクトごとにジョブが分かれるので、複数プロジェクトのビルドが並列実行される。

拡張: ブランドカラーの切り替え

プロジェクトごとにテーマを変えたい場合は tailwind.config.js でも同じパターンが使える。

javascript
// tailwind.config.js const project = process.env.APP_PROJECT || "app-alpha"; const theme = require(`./projects/${project}/theme.js`); module.exports = { content: ["./src/**/*.{js,jsx,ts,tsx}"], presets: [require("nativewind/preset")], theme: { extend: theme }, plugins: [], };

拡張: 機能の出し分け

プロジェクトごとに画面や機能を出し分けるなら、Feature Flagパターンを使う。

typescript
// src/config/features.ts import Constants from "expo-constants"; const projectFeatures: Record<string, { audio: boolean; video: boolean }> = { "app-alpha": { audio: true, video: true }, "app-beta": { audio: true, video: false }, }; const slug = Constants.expoConfig?.slug ?? "app-alpha"; export const Features = projectFeatures[slug] ?? projectFeatures["app-alpha"];

ランタイムで slug からFeature Flagを引くので、ビルド時の設定ミスで機能が混在するリスクがない。

注意点

EAS Update(OTA更新)の分離

各プロジェクトの projectId が異なるため、OTA更新は自動的にプロジェクト単位で分離される。

bash
APP_PROJECT=app-alpha npx eas update --branch production --message "Bug fix"

eas.json のgit管理

ルートの eas.json はデプロイスクリプトが動的にコピーするので、.gitignore に入れるか、デフォルトプロジェクトの設定をコミットしておくかはチームで決める。個人的にはデフォルトプロジェクトの設定をコミットしておくほうが npx eas build を直接叩いたときに壊れないので楽。

prebuild --clean の癖

ローカルで APP_PROJECT を切り替えると ios/android/bundleIdentifier が変わる。expo prebuild は差分更新を試みるが、identifier変更は差分では対応できないことがある。切り替え後は npx expo prebuild --clean を使う癖をつけておく。

まとめ

この設計のポイントは3つ。

  1. projects/ ディレクトリにプロジェクト固有設定を閉じ込める — 共通コードに一切触れずにプロジェクトを追加できる
  2. app.config.ts で動的に読み込む — Expo/EASの標準機能の範囲内で解決する
  3. デプロイスクリプトで eas.json をコピーする — EASの「ルート固定」制約をシンプルに回避する

新しいプロジェクトを追加するときの作業は「ディレクトリを作って設定ファイルを3つ置くだけ」。運用の負荷は最小限で済む。

$ echo $TAGS
#Expo#EAS#React Native#マルチアプリ#CI/CD