◉RORK LABEN
●EXPO — EAS Observe がネイティブクラッシュも記録(10/07)。SDK 57 は 57.0.21 以降で確認できます●RORK — 09-29 に GPT-6.1 Sol、09-28 に Claude Sonnet 5.5 を追加。モデル選択で使えます●SDK 58 — ベータ(9/15)のまま。stable が出る前に、SDK 57 のアプリで確かめておく点を整理しています●Q&A — CPU が 2 コアだと jest が終わらない。CI で詰まる原因を追った記録が共有されています●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った記録が出ています●NEW — 個人アカウントで出した iOS アプリを法人へ移せるか。App Transfer の前に外す5つ●EXPO — EAS Observe がネイティブクラッシュも記録(10/07)。SDK 57 は 57.0.21 以降で確認できます●RORK — 09-29 に GPT-6.1 Sol、09-28 に Claude Sonnet 5.5 を追加。モデル選択で使えます●SDK 58 — ベータ(9/15)のまま。stable が出る前に、SDK 57 のアプリで確かめておく点を整理しています●Q&A — CPU が 2 コアだと jest が終わらない。CI で詰まる原因を追った記録が共有されています●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った記録が出ています●NEW — 個人アカウントで出した iOS アプリを法人へ移せるか。App Transfer の前に外す5つ
記事一覧/アプリ開発
◇ アプリ開発/2026-10-14中級

App Review で「Guideline 2.1・白い画面」と言われたとき、Expo のリリースビルドで疑う3か所

TestFlight では開くのに、審査では「起動後に白い画面のまま」と差し戻される。Expo + EAS のリリースビルドで、環境変数の environment、secret の可視性、審査員の環境差の3点を順に切り分ける手順を、確認コマンドと診断コードつきでまとめました。

App Store 審査Guideline 2.1Expo215EAS Build21環境変数9TestFlight14トラブルシューティング82

審査結果の通知を開くと、「Guideline 2.1 - Performance - App Completeness」の下に「起動後、白い画面のまま先へ進めませんでした」と書かれています。手元の端末では普通に動いているのに、です。胃が痛くなる瞬間だと思います。

私自身、個人開発でアプリを何本も出してきて、審査で差し戻されたことは一度や二度ではありません。そのたびに最初にやってしまうのが、同じビルドをもう一度アップロードすることでした。結果は芳しくありませんでした。審査員の画面が白いのには理由があり、理由を特定しないまま再提出しても、同じ画面がもう一度返ってくるだけなのです。

最初にお伝えしたいのは、疑う順番です。環境変数の environment、secret の可視性、審査員の環境差。 この3か所を上から順に潰していくと、多くのケースで原因に行き着きます。

なお、この記事の確認コマンドや挙動は、Apple と Expo の公式ドキュメントの記述に沿って整理したものです。Apple Developer Forums に実際に投稿されていた同様の相談(Expo SDK 53 + EAS Build、Expo Go では地図のピンが出るのに TestFlight ビルドが白い画面)を読んだうえで、私が点検する順番に並べ直しています。投稿者の環境そのものを再現したわけではない点は、先にお断りしておきます。

差し戻し文面から読み取れること、読み取れないこと

Guideline 2.1 の (a) は、提出物が最終版で、実機でテストされ、ログインがあるならデモアカウントを入れ、バックエンドも稼働させておくこと、そしてクラッシュするバイナリや明らかな技術的問題があるものは差し戻す、という内容です。つまり「白い画面」という指摘は、クラッシュなのか、通信待ちで止まっているのか、設定値が空なのかを区別してくれていません。

読み取れるのは、審査員の環境で、あなたのビルドが最初の画面を描けなかったという事実だけです。原因が JavaScript 側にあるのか、ネイティブ側にあるのかも、この一文からは分かりません。

そこで私は、原因を次の3つに分けて考えています。

  • 設定の問題:ビルドに入るはずの値が入っていない
  • 可視性の問題:値は入っているが、config の解決時点で読めていない
  • 環境差の問題:ビルドは正しいが、審査員の環境ではデータが取れない

上から順に、症状の見分け方と直し方を書いていきます。

開発時と審査時で、何が変わっているのか

Expo Go や開発ビルドで動いた事実は、リリースビルドが動く証拠になりません。動かす主体が変わるからです。

観点手元(Expo Go・開発ビルド)審査(TestFlight・ストアビルド)
JS バンドルMetro が開発サーバから配信ビルド時に埋め込み済み
環境変数ローカルの .env を読むEAS の environment から取り込まれる
ログイン状態開発者のアカウントで通過済み未ログインの新規インストール
権限許可済みのことが多い初回で未許可、または拒否
バックエンド開発用 API に接続本番 API(停止・IP 制限があると応答しない)

この表の中で、白い画面に直結しやすいのは「環境変数」の行です。Expo のドキュメントによれば、EXPO_PUBLIC_ で始まる変数は、バンドルする時点で値そのものに置き換えられます。ビルドの時点で値が無ければ、アプリの中でも値は無いままです。しかも、そのこと自体はエラーとして表に出てきません。

疑う1: 値が「本番用の environment」に入っているか

症状の見分け方は単純です。Expo Go では動くのに、ストア向けビルドだけが白くなります。この組み合わせなら、まず環境変数を疑います。

EAS Build は、ビルドプロファイルの environment で、どの環境の変数を使うかを決めます。公式のドキュメントでは、このフィールドを省略した場合、distribution が store なら production、developmentClient が true なら development、それ以外は preview が自動で選ばれる、と説明されています。

ここに落とし穴があります。開発中に development や preview の環境にだけ変数を作っていた場合、TestFlight に出すストア向けビルドは production を見にいくため、値が1つも入りません。手元では動いていたのですから、気づくきっかけがないのです。

まず、現在の状態を確かめます。

# production 環境に登録されている変数の名前と可視性を一覧する
eas env:list --environment production
 
# preview と見比べて、片方にしか無い名前を探す
eas env:list --environment preview

production 側に、コードが読んでいる名前(たとえば EXPO_PUBLIC_API_URL や地図の API キー)が無ければ、それが原因の最有力です。足すときは次のようにします。

# 値は例です。実際のキーは YOUR_API_KEY の場所に入れてください
eas env:create --name EXPO_PUBLIC_API_URL --value "https://api.example.com" --environment production --visibility plaintext

プロファイルに environment を明示しておくと、あとで読み返したときに迷いません。

{
  "build": {
    "production": {
      "distribution": "store",
      "environment": "production",
      "autoIncrement": true
    },
    "preview": {
      "distribution": "internal",
      "environment": "preview"
    }
  }
}

「省略しても自動で選ばれる」ことと「自分で選んでいる」ことは別物です。私は、審査に出すプロファイルだけは、省略せずに書く線引きにしています。

疑う2: secret は config の解決中には読めない

環境変数を production に入れたのに、まだ白いままの場合、次の候補は可視性です。

EAS の環境変数には、plain text、sensitive、secret の3種類があります。公式のドキュメントには、secret の変数は EAS CLI での config の解決中には使えない、と書かれています。ここで言う config の解決とは、app.config.js や app.config.ts が実行される段階のことです。

たとえば、ネイティブの地図を使うアプリで、API キーを app.config.js から注入しているとします。

// app.config.js
export default ({ config }) => ({
  ...config,
  ios: {
    ...config.ios,
    config: {
      // secret で登録した値は、この時点では undefined になりうる
      googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
    },
  },
});

この値を secret で登録していると、config の解決時には undefined が入る可能性があります。ビルドは通ります。起動もします。ただ、地図の描画だけが静かに失敗して、画面が空になります。

直し方は2通りです。config の解決時に必要な値は sensitive か plain text で登録するか、値をビルド後に取得する設計へ変えるか。どちらを選ぶかは、その値が「アプリに同梱されて困るものか」で決まります。地図 SDK のキーのように、そもそも端末側で使う前提の値は、キー側で制限(バンドル ID の制限など)をかけたうえで同梱するのが普通です。本当に隠したい値は、アプリに入れず、サーバ側(Edge Function など)に置いてください。この線引きについては、接頭辞と可視性を別々の軸として整理した記事があります。EAS の secret は「アプリに入れない」設定ではありませんは、プレミアム側の記事ですが、迷ったときの地図として使っていただけます。

もう1つ、見落としやすい点があります。Expo のドキュメントは、process.env.EXPO_PUBLIC_[VARNAME] のようにドット記法で参照することを求めていて、代替の書き方はサポートされないと明記しています。

// ✅ ドット記法。バンドル時に値へ置き換えられる
const apiUrl = process.env.EXPO_PUBLIC_API_URL;
 
// ❌ 名前を引数にした汎用ヘルパー。サポートされる書き方ではない
const getEnv = (name: string) => process.env[name];
const apiUrl2 = getEnv("EXPO_PUBLIC_API_URL");

「環境変数を読む共通関数」を作りたくなる気持ちは分かります。私もそう書きかけました。ただ、置き換えの対象は文字列として書かれた参照そのものです。便利さのために一段包むと、置き換えの網から外れる恐れがあります。読む場所は、1つのモジュールにドット記法で並べておくのが安全です。

疑う3: 審査員の環境では、データが取れない

環境変数にも可視性にも問題がなく、それでも白い場合は、ビルドではなく環境差を疑います。

審査員は、新規インストール、未ログイン、権限は初回、というまっさらな状態でアプリを開きます。そして、あなたのバックエンドが応答しない、位置情報の許可を拒否される、といった条件に、アプリの側が耐えられるかが問われます。

ここで効くのが、白い画面を作らないという設計です。データが取れないときに、何も描かずに待ち続けるのではなく、何が起きているかを画面に出しておきます。そうすれば、審査員にとっても私たちにとっても、白い画面が「理由のある画面」に変わります。

まず、必須の設定値を起動時に検査します。

// config.ts - 値の読み取りはここだけに集約する(ドット記法)
export const config = {
  apiUrl: process.env.EXPO_PUBLIC_API_URL,
  mapsKey: process.env.EXPO_PUBLIC_MAPS_KEY,
};
 
export function missingConfigKeys(): string[] {
  const missing: string[] = [];
  if (!config.apiUrl) missing.push("EXPO_PUBLIC_API_URL");
  if (!config.mapsKey) missing.push("EXPO_PUBLIC_MAPS_KEY");
  return missing;
}

次に、通信にタイムアウトを付け、失敗を画面に出します。

// useSafeFetch.ts - 応答しない API でも、一定時間後にエラー状態へ移る
import { useEffect, useState } from "react";
 
type State<T> =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "ok"; data: T };
 
export function useSafeFetch<T>(url: string | undefined, timeoutMs = 8000): State<T> {
  const [state, setState] = useState<State<T>>({ status: "loading" });
 
  useEffect(() => {
    if (!url) {
      setState({ status: "error", message: "接続先が設定されていません" });
      return;
    }
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
 
    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json() as Promise<T>;
      })
      .then((data) => setState({ status: "ok", data }))
      .catch((e: Error) =>
        setState({ status: "error", message: e.name === "AbortError" ? "通信がタイムアウトしました" : e.message })
      )
      .finally(() => clearTimeout(timer));
 
    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [url, timeoutMs]);
 
  return state;
}

8 秒という値は、私の感覚で置いた初期値です。回線の細い環境で審査される可能性を考えると、短すぎず長すぎない範囲だと考えています。皆さまのアプリの性質に合わせて調整してください。

こうしておくと、再提出のときに差し戻しの文面が変わります。「白い画面」ではなく「接続先が設定されていません」と表示される状態なら、スクリーンショットを見ただけで、私たち自身が原因を言い当てられます。

あわせて、App Store Connect の審査メモにも書いておきます。ログインが必要ならデモアカウント、バックエンドが特定の条件でしか応答しないならその条件、位置情報が必要な画面ならその旨です。Guideline 2.1 は、まさにここを求めています。

直らないときの次の一手

ここまでで特定できないときは、証拠を取りにいきます。

  1. 実機にストア向けビルドを入れ、Mac に接続してコンソールを見ます。 Xcode の Devices and Simulators から該当端末を選び、Open Console で起動直後のログを確認します。クラッシュなのか、例外を握りつぶしているのかが、ここで分かれます。
  2. TestFlight のクラッシュフィードバックと、Xcode の Organizer を確認します。 クラッシュなら、ログが残っています。残っていなければ、クラッシュではなく「描けていない」側です。
  3. 審査員の端末と条件を想像して、手元で寄せます。 新規インストール、未ログイン、権限はすべて拒否、機内モード。この条件で起動し、画面に何が出るかを見ます。

再提出のときに、ビルド番号の壁に当たることもあります。以前に上げたビルドより大きい番号でなければ受け付けられないため、eas build:version:get で EAS 側の現在値を確認し、必要なら eas build:version:set で合わせます。これは白い画面の原因ではありませんが、原因を直した直後に足止めされると、気持ちが折れやすいところです。

最後に、原則を1つだけ書き残します。

白い画面は、何も描かないことではなく、理由を描かないことが問題です。

まずは、お手元のアプリで eas env:list --environment production を実行して、コードが読んでいる名前がすべて並んでいるかを確かめるところから始めていただければと思います。私も、審査に出す前の最後の確認として、この1行を習慣にしています。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

◇ アプリ開発2026-10-09
Expo Go で通知だけが届かない夜に、先に切り分ける3点と development build への最短の移り方
Android 実機の Expo Go でプッシュ通知が届かないとき、端末設定を疑う前に確かめる3点と、development build に移る最小の手順をまとめました。通知以外に Expo Go で試せないものの見分け方も添えます。
◇ アプリ開発2026-09-10
Rork のリポジトリに置く最初の EAS Workflow と、失敗した回にだけ届かない通知
Rork から書き出したリポジトリに .eas/workflows を二本置くまでの手順と、needs でつないだ通知が失敗した回にだけ届かなくなる理由を、手元で走る検査スクリプトの実行結果つきで書き残します。
◇ アプリ開発2026-09-02
eas.json に image を書かないままだと、SDK を上げた日に Xcode も pnpm も一緒に動きます
eas.json に image を書いていないビルドの既定は auto です。SDK エイリアスが1つ動くと Xcode・Node.js・pnpm・fastlane がまとめて入れ替わります。Xcode 27 が来る前に、自分のビルドが通ったイメージ名をログから拾い、固定するかどうかを決めるまでの手順です。