You open the review result and read, under "Guideline 2.1 - Performance - App Completeness": "the app showed a blank white screen after launch." On your own phone it runs perfectly. That sinking feeling in the stomach is familiar to me.
As an indie developer who has shipped a number of apps, I've been rejected more than once, and my first instinct every time was to upload the same build again. It didn't go well. The reviewer's screen is blank for a reason, and resubmitting without finding the reason just returns the same screenshot.
What I'd like to tell you first is the order I check things in: which environment holds your variables, secret visibility, and the reviewer's environment. Working through those three, top to bottom, resolves most cases I've seen described.
One caveat. The commands and behaviors below follow Apple's and Expo's official documentation. I also read a real post on the Apple Developer Forums describing the same symptom (Expo SDK 53, EAS Build, a map that shows pins in Expo Go but a blank screen in TestFlight), and I've rearranged the ideas into the order I'd check them. I did not reproduce that poster's exact setup.
What a rejection message does and doesn't tell you
Guideline 2.1(a) asks for a final, on-device-tested build, a demo account if there is a login, and a running back end. Apple rejects binaries that crash or show obvious technical problems. "Blank screen" doesn't say whether the app crashed, is waiting on a network call, or has an empty setting.
All you can read from it is that the app failed to draw a first screen in the reviewer's environment. So I split the causes into three:
- A value that should be in the build isn't (a configuration problem).
- The value exists but can't be read at config-resolution time (a visibility problem).
- The build is fine, but the reviewer's environment can't get the data (an environment problem).
What changes between your device and review
A pass in Expo Go or a development build is not evidence for a release build, because who runs the app has changed.
| Aspect | Your device (Expo Go / dev build) | Review (TestFlight / store build) |
|---|---|---|
| JS bundle | Served live by Metro | Embedded at build time |
| Env variables | Local .env file | Pulled from an EAS environment |
| Login state | Already signed in | Fresh install, signed out |
| Permissions | Usually granted | Not yet asked, or denied |
| Back end | Dev API | Production API (silent if down or IP-restricted) |
The "env variables" row is the one most likely to produce a blank screen. Per Expo's documentation, any EXPO_PUBLIC_ variable is replaced by its literal value when the app is bundled. If no value existed at build time, none exists in the app, and nothing surfaces that as an error.
Suspect 1: are the values in the production environment?
The symptom is simple: it works in Expo Go and only the store build is blank. Look at environment variables first.
EAS Build chooses which environment to read through the environment field on the build profile. Expo's documentation says that when you omit it, EAS picks production if distribution is store, development if developmentClient is true, and preview otherwise.
Here's the trap. If you created your variables only in development or preview while building, the store build you send to TestFlight reads production and gets nothing. It worked on your phone, so nothing prompted you to look.
Check what's actually there:
# list the names and visibility of variables in production
eas env:list --environment production
# compare with preview and look for names present in only one
eas env:list --environment previewIf a name your code reads (say EXPO_PUBLIC_API_URL or a maps key) is missing from production, that's your leading suspect. To add it:
# the value is an example; put your real key where YOUR_API_KEY would go
eas env:create --name EXPO_PUBLIC_API_URL --value "https://api.example.com" --environment production --visibility plaintextWriting environment explicitly in the profile saves you from guessing later:
{
"build": {
"production": {
"distribution": "store",
"environment": "production",
"autoIncrement": true
},
"preview": {
"distribution": "internal",
"environment": "preview"
}
}
}"It gets picked automatically" and "I chose it" are different things. For the profile I submit to review, I never leave it implicit.
Suspect 2: secrets can't be read while the config resolves
Variables are in production and the screen is still blank? Next, visibility.
EAS variables come in three types: plain text, sensitive, and secret. The documentation states that secret variables are not available during config resolution in EAS CLI, meaning the stage where app.config.js or app.config.ts runs.
Suppose a native map app injects its key from app.config.js:
// app.config.js
export default ({ config }) => ({
...config,
ios: {
...config.ios,
config: {
// a value stored as secret may be undefined at this point
googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
},
},
});If that value is a secret, it can arrive as undefined. The build succeeds and the app launches; only the map quietly fails to draw and you get an empty screen.
There are two ways out: register values needed at config time as sensitive or plain text, or change the design so the value is fetched after the build. The deciding question is whether the value is something you can afford to ship inside the app. A maps SDK key is meant for the device anyway, so restrict it on the provider side (by bundle ID, for example) and bundle it. Anything you truly must hide belongs on a server, such as an edge function. I wrote up the prefix-versus-visibility distinction as two separate axes in a premium article, EAS secrets are not a "keep it out of the app" switch, if you want a map for that decision.
One more thing that's easy to miss: Expo's documentation asks for dot notation, process.env.EXPO_PUBLIC_[VARNAME], and says alternative forms are not supported.
// ✅ dot notation: replaced with the literal value at bundle time
const apiUrl = process.env.EXPO_PUBLIC_API_URL;
// ❌ a generic helper taking the name: not a supported form
const getEnv = (name: string) => process.env[name];
const apiUrl2 = getEnv("EXPO_PUBLIC_API_URL");I understand the pull of a shared "read an env var" helper; I nearly wrote one myself. But the replacement targets the reference as written. Wrapping it for convenience can take it out of reach. I keep every read in one module, in dot notation.
Suspect 3: the reviewer's environment can't get the data
Variables fine, visibility fine, still blank? Suspect the environment rather than the build.
A reviewer opens a fresh install, signed out, with permissions not yet granted. Your server may not respond; location access may be denied. The question is whether your app survives that.
This is where never producing a blank screen helps. Instead of waiting silently when data can't load, show what's happening. A blank screen then becomes a screen with a reason, for the reviewer and for you.
First, check required settings at startup:
// config.ts - all reads live here (dot notation)
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;
}Then give network calls a timeout and surface failures:
// useSafeFetch.ts - even an unresponsive API ends in an error state
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: "No server URL is configured" });
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" ? "The request timed out" : e.message })
)
.finally(() => clearTimeout(timer));
return () => {
clearTimeout(timer);
controller.abort();
};
}, [url, timeoutMs]);
return state;
}Eight seconds is just my starting value; given a reviewer might be on a slow connection, it's a range I'd call neither too short nor too long. Tune it to your app.
With this in place, the next rejection reads differently. "No server URL is configured" in a screenshot lets you name the cause yourself.
Also fill in the review notes in App Store Connect: a demo account if there's a login, the conditions under which your back end responds, and any screen that needs location. That is exactly what Guideline 2.1 asks for.
When it still isn't fixed
If you still can't pin it down, go collect evidence.
- Install the store build on a real device, connect it to a Mac, and read the console. In Xcode's Devices and Simulators, select the device and open the console to see the logs from launch. It tells you whether the app crashed or swallowed an exception.
- Check TestFlight crash feedback and Xcode Organizer. If it crashed, logs exist. If none exist, the app didn't crash; it failed to draw.
- Imitate the reviewer. Fresh install, signed out, every permission denied, airplane mode. Launch under those conditions and see what appears.
You may also hit a build-number wall on resubmission: a new upload must exceed earlier ones. Run eas build:version:get to see EAS's current value and eas build:version:set to align it if needed. It isn't the cause of a blank screen, but being stopped right after fixing the real problem is a good way to lose heart.
One principle to leave you with:
The problem with a blank screen isn't that nothing is drawn; it's that no reason is drawn.
As a first step, run eas env:list --environment production and check that every name your code reads is there. Before I submit anything, that one line is my last check.