Sunstone Apps
← Back to blog

Expo Native Builds

When an Expo upgrade turns your splash screen into a white screen

A white screen with a default spinner is not a design issue. After an Expo SDK upgrade, it is often a native configuration issue waiting for the next prebuild.

Published: 8 min read
OpenGraph preview image for this article. When an Expo upgrade turns your splash screen into a white screen

The uncomfortable part of a splash screen bug is that it can look like nothing happened. The app still starts. The JavaScript loading screen still renders. Tests stay green. But the first few seconds of the native launch suddenly show a blank white screen with a generic progress indicator instead of the branded splash screen the app used to have.

That is what we saw after a dependency upgrade in a React Native and Expo Sudoku app. Before the upgrade, the native splash screen showed the app icon on the same pale background used by the JavaScript splash component. After the upgrade, Android showed a plain white launch screen with a couple of loading circles in the middle. The app did not crash. There was no obvious TypeScript error. The visible regression happened before the React tree had enough control to fix it.

The cause was not the React splash component. It was the native splash configuration.

For a while, the project still used the legacy splash block in app.json. That looked reasonable because it had the same fields many Expo apps have used for years: an image, a resize mode, and a background color.

But after the SDK upgrade, relying on that legacy shape was the wrong source of truth. The current Expo path is the expo-splash-screen package and its config plugin. That plugin owns the native files generated during prebuild. If it is not installed and configured, the app can easily fall back to the platform’s default launch screen even though the JavaScript component looks correct once it appears.

First separate native splash from JS splash

The first useful question was not “why is my splash component white?” It was “which splash is white?”

Most Expo apps have two different moments that people call “the splash screen.” The first is native. It is drawn by Android or iOS before JavaScript is ready. The second is app-rendered. It is the React component you show while bootstrapping database state, translations, consent, purchases, or remote config.

Those two screens can use the same artwork and color, but they are not controlled by the same code.

In our app, the React splash component was straightforward. It rendered a full screen view, used the app icon, and used the same background color that used to be declared in app.json. That part still worked. Once JavaScript loaded, the app showed the branded loading screen with the message and error state.

The broken part happened earlier. Android showed the generic white launch screen before the React component mounted. That means changing the React component would have been busywork. The native launch screen needed to be regenerated with the right plugin.

This distinction matters because it keeps the fix small. Without it, it is tempting to add more fallback views, change the root background, hide status bars earlier, or delay app startup. None of those changes writes the native splash resources.

Check the app config, not just the component

The project had a normal-looking app.json: app icon, adaptive icon, splash image, and a list of config plugins. The suspicious part was what was missing. There was no expo-splash-screen dependency in the app package, and there was no expo-splash-screen entry in the plugins array.

That gap is easy to miss because the legacy splash block still looks like a configuration. It is also still present in plenty of older apps, examples, and internal docs. But Expo’s current documentation recommends the config plugin because it can configure the native properties that require a new binary.

The right shape became:

{
  "expo": {
    "plugins": [
      [
        "expo-splash-screen",
        {
          "image": "./assets/icon.png",
          "imageWidth": 112,
          "resizeMode": "contain",
          "backgroundColor": "#F8FAFC"
        }
      ]
    ]
  }
}

We kept the values intentionally boring. Same icon. Same background. Same contain behavior. The only sizing choice was imageWidth: 112, which matched the JavaScript splash image size. That does not have to be universal. It simply made the native-to-JS transition look consistent in this app.

The important part was not the exact number. The important part was moving ownership of the native launch screen to the plugin.

Install with the workspace’s package manager

The fix sounds like one command:

expo install expo-splash-screen

In a real monorepo, even that can have a trap. This workspace declared packageManager: [email protected], but the global pnpm binary on the machine was still 9.15.4. Running expo install tried to add the package through the wrong pnpm major and hit an unexpected store-location error.

That failure was useful. It prevented the dependency from being added with a different package-manager layout. The fix was not to change global pnpm config. It was to use the version the repository declares:

npx [email protected] --filter sudoku add expo-splash-screen@~57.0.1

Expo had already told us the SDK-compatible version: ~57.0.1. We used that version, updated the lockfile, and left the global developer environment alone.

If you see this kind of store mismatch during a native dependency install, pause before “fixing” the machine. In a shared repo, the package manager version is part of the build contract. The local shell is just one way to run it.

Remove the legacy config once the plugin exists

After adding the plugin, we removed the old splash block. Keeping both would have made the app config harder to reason about. Two places would appear to define the same launch behavior, but only one is the source we want to trust.

This is one of those small cleanups that prevents the next bug. If a future developer changes a legacy splash background while the plugin keeps a different backgroundColor, the repository has just created a new disagreement. The app might still build, but the next investigation starts with a false clue.

We also updated the comment in the React splash component. It used to say that the background matched the legacy splash config. That was true before the migration and misleading after it. The comment now points to the expo-splash-screen plugin config.

Comments near visual bridge code are worth keeping honest. A splash transition is a contract between native resources and React UI. If the comment names the wrong source of truth, it becomes worse than no comment.

Remember that native splash changes need a native build

One subtle detail: changing app.json and the lockfile is not enough to see the fix on a device that already has an old native binary installed.

The plugin writes native resources during prebuild and those resources ship in the APK, AAB, or iOS build. In our workflow, the release-like Android path already runs:

expo prebuild --platform android --no-install

through the repository’s pnpm sudoku:apk and pnpm sudoku:aab scripts. That means the next release APK will apply the plugin without an extra manual step. But a stale installed build will not magically change its launch screen just because JavaScript changed.

This matters for validation. If the bug is native splash, do not validate only with TypeScript, unit tests, or a web build. Those checks are still useful, but they cannot prove the native launch screen is fixed. You need a fresh native build or prebuild output.

We still ran the normal checks:

  • TypeScript, to catch config imports and package typing issues;
  • app lint, to catch React and Expo integration problems;
  • expo config --type public, to confirm that the plugin appears in the resolved config and the legacy splash block is gone;
  • Prettier, because JSON and docs churn should not add noise.

Those checks made the change safe to commit. The final visual confirmation belongs to a rebuilt app.

The practical checklist

When a splash screen regresses after an Expo upgrade, this is the checklist we would run before touching visual code:

  1. Identify whether the broken screen is native launch or React-rendered loading UI.
  2. Check whether expo-splash-screen is installed in the app package.
  3. Check whether expo-splash-screen is configured in the plugins array.
  4. Move image, width, resize mode, and background color into the plugin config.
  5. Remove the legacy splash block if the plugin is now the source of truth.
  6. Update comments and docs that still mention the old source.
  7. Run expo config --type public and confirm the resolved config.
  8. Rebuild the native app before judging the result on device.

The lazy fix is not to add another loading component. The lazy fix is to make the correct native config boring and singular.

In this case, the code change was small: one dependency, one plugin entry, one legacy block removed, and one comment updated. The value was not in cleverness. It was in refusing to debug a native launch problem from inside React.

That is usually the lesson with Expo upgrades. The app can look almost the same while the build contract has moved. When that happens, the right fix is not more UI. It is finding the source of truth that still participates in the binary.