Reproduction
Minimal repro: https://github.com/ditto-agent/react-router-prerender-suspense-flash
git clone https://github.com/ditto-agent/react-router-prerender-suspense-flash
cd react-router-prerender-suspense-flash
bun install
bunx react-router build
grep -c "SUSPENSE FALLBACK" build/client/index.html
# => 2 (one in the shell as the deferred fallback, one in the bundled JS)
Or to reproduce in your own app:
- Configure
ssr: true in react-router.config.ts and add a route to prerender.
- Enable
v8_splitRouteModules: 'enforce' (any reasonably-sized app with many routes will reproduce; this flag just makes the head large enough to trip the threshold reliably).
- In
root.tsx, wrap <Outlet /> in <Suspense fallback={<LoadingScreen />}> — a common pattern for protecting against descendant useSuspenseQuery calls. The fallback should be visibly distinct (e.g. a centered logo).
npm run build. Open build/client/<prerendered-route>/index.html.
System Info
System:
OS: macOS 26.3.1
CPU: (12) arm64 Apple M2 Pro
Memory: 376.53 MB / 32.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 24.14.1 - /Users/ditto/.local/state/fnm_multishells/99520_1778257878168/bin/node
Yarn: 1.22.22 - /opt/homebrew/bin/yarn
npm: 11.11.0 - /Users/ditto/.local/state/fnm_multishells/99520_1778257878168/bin/npm
bun: 1.3.11 - /nix/store/cgzjp11cgzg8n349rqmbqz46izm2kl2k-bun-1.3.11/bin/bun
Browsers:
Chrome: 147.0.7727.139
Safari: 26.3.1
npmPackages:
@react-router/dev: 7.13.0 => 7.13.0
@react-router/express: 7.13.0 => 7.13.0
@react-router/fs-routes: 7.13.0 => 7.13.0
@react-router/node: 7.13.0 => 7.13.0
react-router: 7.13.0 => 7.13.0
vite: 7.3.1 => 7.3.1
Used Package Manager
npm
Expected Behavior
The <body> of the prerendered file contains the route's rendered content directly. When the file is served at runtime, the user sees the actual page on first paint.
Actual Behavior
The <body> opens with React's deferred-Suspense markup — the fallback is rendered in the shell, and the actual route content sits below in a hidden <div> paired with an inline $RC() swap script:
<body class="overflow-hidden">
<!--$?--><template id="B:0"></template>
<div class="fixed inset-0 ..."><img alt="Loading Logo" .../></div>
<!--/$-->
...
<div hidden id="S:0"><div>... actual route content ...</div></div>
<script>$RC("B:0","S:0")</script>
</body>
When the browser loads this static HTML, it paints the LoadingScreen first; the inline $RC() script then swaps in the real content. The result is a visible fallback flash on initial paint, even though nothing inside the Suspense boundary
actually suspended during the SSR render.
Reproduction
Minimal repro: https://github.com/ditto-agent/react-router-prerender-suspense-flash
Or to reproduce in your own app:
ssr: trueinreact-router.config.tsand add a route toprerender.v8_splitRouteModules: 'enforce'(any reasonably-sized app with many routes will reproduce; this flag just makes the head large enough to trip the threshold reliably).root.tsx, wrap<Outlet />in<Suspense fallback={<LoadingScreen />}>— a common pattern for protecting against descendantuseSuspenseQuerycalls. The fallback should be visibly distinct (e.g. a centered logo).npm run build. Openbuild/client/<prerendered-route>/index.html.System Info
System: OS: macOS 26.3.1 CPU: (12) arm64 Apple M2 Pro Memory: 376.53 MB / 32.00 GB Shell: 5.9 - /bin/zsh Binaries: Node: 24.14.1 - /Users/ditto/.local/state/fnm_multishells/99520_1778257878168/bin/node Yarn: 1.22.22 - /opt/homebrew/bin/yarn npm: 11.11.0 - /Users/ditto/.local/state/fnm_multishells/99520_1778257878168/bin/npm bun: 1.3.11 - /nix/store/cgzjp11cgzg8n349rqmbqz46izm2kl2k-bun-1.3.11/bin/bun Browsers: Chrome: 147.0.7727.139 Safari: 26.3.1 npmPackages: @react-router/dev: 7.13.0 => 7.13.0 @react-router/express: 7.13.0 => 7.13.0 @react-router/fs-routes: 7.13.0 => 7.13.0 @react-router/node: 7.13.0 => 7.13.0 react-router: 7.13.0 => 7.13.0 vite: 7.3.1 => 7.3.1Used Package Manager
npm
Expected Behavior
The
<body>of the prerendered file contains the route's rendered content directly. When the file is served at runtime, the user sees the actual page on first paint.Actual Behavior
The
<body>opens with React's deferred-Suspense markup — the fallback is rendered in the shell, and the actual route content sits below in ahidden<div>paired with an inline$RC()swap script:When the browser loads this static HTML, it paints the LoadingScreen first; the inline
$RC()script then swaps in the real content. The result is a visible fallback flash on initial paint, even though nothing inside the Suspense boundaryactually suspended during the SSR render.