EdgeOne Pages React Router Starter - Streaming SSR
Progressive rendering with deferred data loading for optimal performance.
The page shell and fast data arrive instantly, while slow data streams in progressively. This provides the best user experience by showing content as soon as possible.
⚡ Fast Data (Instant)
This data loaded immediately and was included in the initial HTML response. The page shell arrived in milliseconds!
Message: This loaded instantly!
Timestamp: 2026-03-16T08:50:05.762Z
Type: fast
Features
• Instant Shell: HTML shell with fast data arrives immediately
• Progressive Enhancement: Slow data streams in as it becomes available
• Better UX: Users see content faster instead of waiting for everything
• Server Streaming: Uses renderToPipeableStream to send HTML in chunks
• Client Navigation: Works for both SSR and client-side navigation
• Suspense Boundaries: React Suspense handles loading states automatically
🌊 Slow Data (Streaming - 3 seconds)
This data is being fetched in the background and will stream in when ready. Watch the loading state!
The page is already interactive! You can scroll and interact while this loads.
🐌 Very Slow Data (Streaming - 5 seconds)
This data takes even longer to fetch. Notice how the page is fully functional while waiting!
Still loading, but the rest of the page is already rendered and interactive!
🔍 How Streaming Works
1. Server Receives Request
The loader starts executing. Fast data is awaited, slow data is returned as Promise (not awaited).
2. Initial HTML Chunk Sent
React's renderToPipeableStream sends the HTML shell with fast data immediately. The browser can start rendering!
3. Suspense Boundaries Show Fallbacks
Areas waiting for slow data show loading states. The page is already interactive!
4. Slow Data Streams In
As Promises resolve, React streams additional HTML chunks to replace the fallbacks.
5. Client-Side Navigation
The clientLoader uses the same Promise pattern, so navigation feels instant even with slow data!
✨ Benefits of Streaming
Faster Time to First Byte (TTFB)
The server sends HTML immediately without waiting for all data
Better Perceived Performance
Users see content faster and can interact sooner
Progressive Enhancement
Content appears progressively as data becomes available
Optimal Resource Usage
Server doesn't block on slow operations, improving throughput
📋Entry Files Setup
React Router v7 enables streaming SSR by returning unresolved Promises directly from loaders. When the server encounters a Promise, it sends the initial HTML shell immediately with Suspense fallbacks, then streams resolved data as script tags that hydrate into the page. This requires proper configuration in both entry files to handle the streaming lifecycle.
entry.server.tsx
Uses renderToPipeableStream with onShellReady callback to send the initial HTML shell immediately (for users) or onAllReady (for bots). The stream pipes React's rendered output through a PassThrough stream to a Web ReadableStream, enabling progressive HTML delivery as deferred Promises resolve.
entry.client.tsx
Uses hydrateRoot wrapped in startTransition to hydrate the server-rendered HTML. The HydratedRouter automatically picks up streamed data and seamlessly transitions from server-rendered fallbacks to resolved content without full re-renders.
import { Suspense } from "react";
import { Await } from "react-router";
// Fast data - returns immediately
async function getFastData() {
return { message: "Instant!" };
}
// Slow data - takes 3 seconds
async function getSlowData() {
await new Promise(resolve => setTimeout(resolve, 3000));
return { message: "Took 3 seconds!" };
}
// Server loader - return Promise directly (v7)
export async function loader() {
const fastData = await getFastData();
const slowData = getSlowData(); // Don't await!
// Just return object with Promise - no defer() needed in v7
return {
fastData,
slowData, // This Promise will stream
};
}
// Client loader - same pattern for navigation
export async function clientLoader() {
const fastData = await getFastData();
const slowData = getSlowData();
return {
fastData,
slowData,
};
}
clientLoader.hydrate = true;
export default function StreamingPage({ loaderData }) {
return (
<div>
{/* Fast data renders immediately */}
<h2>Fast Data: {loaderData.fastData.message}</h2>
{/* Slow data streams in later */}
<Suspense fallback={<div>Loading slow data...</div>}>
<Await resolve={loaderData.slowData}>
{(data) => <p>Slow Data: {data.message}</p>}
</Await>
</Suspense>
</div>
);
}