◉RORK LABJP
●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, check for 57.0.21 or later●RORK — GPT-6.1 Sol (09-29) and Claude Sonnet 5.5 (09-28) are in the model menu●SDK 58 — Still in beta (since Sep 15). What to check on an SDK 57 app before the stable release●Q&A — Jest never finishes on a 2-core CPU. A write-up traces the cause in CI●WIDGET — A write-up traces why an iOS widget goes black only in production builds●NEW — Moving an iOS app from a personal account to an organization: five things to clear before App Transfer●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, check for 57.0.21 or later●RORK — GPT-6.1 Sol (09-29) and Claude Sonnet 5.5 (09-28) are in the model menu●SDK 58 — Still in beta (since Sep 15). What to check on an SDK 57 app before the stable release●Q&A — Jest never finishes on a 2-core CPU. A write-up traces the cause in CI●WIDGET — A write-up traces why an iOS widget goes black only in production builds●NEW — Moving an iOS app from a personal account to an organization: five things to clear before App Transfer
Articles/App Dev
◇ App Dev/2026-10-14Intermediate

App Review Says "Guideline 2.1, Blank Screen": Three Places to Look in an Expo Release Build

It opens fine in TestFlight, yet review rejects it for a blank screen. Here is the order I check things in an Expo + EAS release build: which environment holds your variables, secret visibility during config resolution, and what the reviewer's device does differently.

App Store review3Guideline 2.1Expo215EAS Build21environment variables5TestFlight15troubleshooting68

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.

AspectYour device (Expo Go / dev build)Review (TestFlight / store build)
JS bundleServed live by MetroEmbedded at build time
Env variablesLocal .env filePulled from an EAS environment
Login stateAlready signed inFresh install, signed out
PermissionsUsually grantedNot yet asked, or denied
Back endDev APIProduction 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 preview

If 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 plaintext

Writing 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.

  1. 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.
  2. Check TestFlight crash feedback and Xcode Organizer. If it crashed, logs exist. If none exist, the app didn't crash; it failed to draw.
  3. 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.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

◇ App Dev2026-09-10
Your First EAS Workflow in a Rork Repo, and the Alert That Goes Missing Exactly When You Need It
Putting two files into .eas/workflows in a repo exported from Rork, and why a notification wired with needs stays silent on exactly the nights it fails — with the output of a small local checker I actually ran.
◇ App Dev2026-09-02
Leave image out of eas.json and an SDK bump moves your Xcode and pnpm too
If eas.json has no image field, your builds run on the auto alias. Move one SDK alias and Xcode, Node.js, pnpm and fastlane all change together. Here is how to read the image name out of your last green build and decide whether to pin it before Xcode 27 lands.
◇ App Dev2026-08-15
TestFlight External Testing or Phased Release: Where a Solo Developer's Time Actually Pays Off
When you ship a Rork-built app to other people, you can recruit external testers or let a phased release absorb the risk. Here is how I split my limited time between the two, based on where distribution actually stalls and which three areas still deserve pre-release checks.