React Native(Expo)で開発したアプリを、ブランド違いや用途違いで複数のアプリとしてストアに公開したいケースがあります。
こうしたとき「リポジトリをフォークして別管理」にすると、共通コードの同期が地獄になります。この記事では、1つのコードベースを維持したまま、EAS Build / Submit でプロジェクトごとに異なる設定でビルド・デプロイする設計パターンを紹介します。
EAS Build には以下の制約があります。
eas.json はプロジェクトルート固定 — パスの指定ができないapp.json(または app.config.ts)もルート固定 — Expo CLIが読み取るextra.eas.projectId でExpoダッシュボード上のプロジェクトが決まるつまり、プロジェクトごとに app.json と eas.json の中身を切り替える仕組みが必要です。
設計の出発点として、差分を明確にします。
| ファイル | 差分内容 |
|---|---|
app.json | name, slug, bundleIdentifier, package, projectId, scheme, icon, splash |
eas.json | submit設定(Apple ID, ASC App ID, Team ID, Google Play設定) |
assets/ | アイコン・スプラッシュ画像 |
.env | 認証情報、API URL、Firebase設定 |
| ファイル | 内容 |
|---|---|
src/ | 全画面・コンポーネント・hooks・サービス |
metro.config.js | Metro Bundler設定 |
babel.config.js | Babel設定 |
tailwind.config.js | NativeWindテーマ |
package.json | 依存関係 |
tsconfig.json | TypeScript設定 |
ポイントは projects/ ディレクトリにプロジェクトごとの設定を閉じ込め、共通コードには一切手を加えないことです。
app.json を直接使う代わりに app.config.ts を作成し、環境変数 APP_PROJECT でプロジェクト設定を切り替えます。
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 CLIは eas.json のパスを変更できないため、ビルド前にプロジェクト固有の eas.json をルートにコピーします。これはデプロイスクリプトで自動化します。
#!/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
}
# 単一プロジェクトをビルド
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"
exit 1
fi
# eas.json をプロジェクト固有のものに差し替え
if [ -f "$project_dir/eas.json" ]; then
cp "$project_dir/eas.json" "$MOBILE_DIR/eas.json"
fi
# APP_PROJECT を設定してEASビルドを実行
cd "$MOBILE_DIR"
APP_PROJECT="$project" npx eas build "${eas_args[@]}"
}
# 元の eas.json をバックアップ & 復元
trap cleanup EXIT
# 各プロジェクトをビルド
for project in "${PROJECTS[@]}"; do
build_project "$project" "${EAS_ARGS[@]}"
done
{
"expo": {
"name": "My App Alpha",
"slug": "my-app-alpha",
"version": "1.0.0",
"ios": {
"bundleIdentifier": "com.example.alpha",
"newArchEnabled": true
},
"android": {
"package": "com.example.alpha",
"newArchEnabled": true
},
"scheme": "myapp-alpha",
"extra": {
"eas": {
"projectId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
}
}
}
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"env": { "APP_ENV": "development" }
},
"production": {
"env": { "APP_ENV": "production" }
}
},
"submit": {
"production": {
"ios": {
"appleId": "dev@example.com",
"ascAppId": "1234567890",
"appleTeamId": "ABCDE12345"
}
}
}
}
# 特定プロジェクトで開発サーバーを起動
APP_PROJECT=app-alpha npx expo start
# 単一プロジェクト
./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
{
"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"
}
}
npx eas init で新しいプロジェクトを作成し、projectIdを控えるprojects/ にディレクトリを作成 — mkdir -p projects/app-gamma/assets/images./scripts/eas-deploy.sh --project app-gamma --profile development --platform iosEAS Buildでは npx expo prebuild が毎回実行され、app.config.ts の設定に基づいてネイティブプロジェクトが生成されます。そのため:
ios/, android/ は .gitignore に入れておくbundleIdentifier や package が変わっても、prebuildが正しいネイティブプロジェクトを生成するplugins で管理するname: EAS Build
on:
workflow_dispatch:
inputs:
projects:
description: 'Projects to build (comma-separated, or "all")'
default: 'all'
profile:
description: 'Build profile'
type: choice
options: [development, preview, production]
platform:
description: 'Platform'
type: choice
options: [ios, android, all]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
project: ${{ fromJson(...) }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: expo/expo-github-action@v8
- 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 --non-interactive
matrix を使うことで、複数プロジェクトのビルドが並列実行されます。
EAS Updateを使う場合、各プロジェクトの projectId が異なるため、OTA更新は自動的にプロジェクト単位で分離されます。
APP_PROJECT=app-alpha npx eas update --branch production --message "Bug fix"
現時点では tailwind.config.js は共通ですが、プロジェクトごとにテーマを変えたい場合は app.config.ts と同様のパターンで動的に読み込めます。
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パターンを使います。
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"];
この設計のポイントは3つです。
projects/ ディレクトリにプロジェクト固有設定を閉じ込める — 共通コードに一切触れずにプロジェクトを追加できるapp.config.ts で動的に読み込む — Expo/EASの標準機能の範囲内で解決するeas.json をコピーする — EASの「ルート固定」制約をシンプルに回避する新しいプロジェクトを追加するときの作業は「ディレクトリを作って設定ファイルを3つ置くだけ」なので、運用の負荷は最小限です。