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!

Loading slow data... (3 seconds)

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!

Loading very slow data... (5 seconds)

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>
  );
}