Frontend Development 34 min read

Streaming Rendering with React 18: Next.js, Remix, and Custom SSR Implementations

This article explains the concept of streaming (incremental) rendering introduced in React 18, demonstrates how to apply it in Next.js and Remix using server components and Suspense, and walks through a custom SSR setup that leverages renderToPipeableStream and the upcoming use hook for seamless data fetching.

Ctrip Technology
Ctrip Technology
Ctrip Technology
Streaming Rendering with React 18: Next.js, Remix, and Custom SSR Implementations

React 18 introduced a streaming rendering feature that allows HTML to be sent to the client in small chunks, reducing Time‑to‑First‑Byte (TTFB) and improving perceived performance. The article begins with an overview of streaming, explaining how it works with HTTP chunked transfer encoding and HTTP/2 data frames.

Next.js Example

Using npx [email protected] , a basic project is set up. The app/page.tsx file is modified to fetch comments with a simulated 3‑second delay and render them. The initial implementation blocks rendering until the comments are fetched, causing a white‑screen delay.

// 获取商品评论信息(延迟3s)
function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export default async function Home() {
  const comments = await getComments();
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div>
        <div>商品</div>
        <p>价格</p>
        <div>
          <p>评论</p>
          <input />
          <div>
            {comments.map((comment) => {
              return <p key={comment}>{comment}</p>;
            })}
          </div>
        </div>
      </div>
    </main>
  );
}

To avoid blocking, the comment fetching is moved into a server component wrapped with <Suspense> :

// components/Comment.tsx
function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export default async function Comments() {
  const comments = await getComments();
  return (
    <div>
      <p>评论</p>
      <input />
      {comments.map((comment) => {
        return <p key={comment}>{comment}</p>;
      })}
    </div>
  );
}

// app/page.tsx
import Comment from '@/components/Comments';
import { Suspense } from 'react';
export default async function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div>
        <div>商品</div>
        <p>价格</p>
        <div>
          <Suspense fallback={<div>Loading...</div>}>
            <Comment />
          </Suspense>
        </div>
      </div>
    </main>
  );
}

Attempting to add client‑side interactivity (e.g., an input field) inside a server component throws an error because server components cannot use hooks or browser APIs. The solution is to separate interactive logic into a client component marked with 'use client' and nest it inside the server component.

// components/EditableComments.tsx
'use client';
import { useRef } from 'react';
export default function EditableComments({ comments }) {
  const inputRef = useRef<HTMLInputElement>(null);
  const onSubmit = () => {
    alert(`您提交的评论内容:${inputRef.current?.value}`);
  };
  return (
    <>
      <input ref={inputRef} />
      <button onClick={onSubmit}>提交评论</button>
    </>
  );
}

// components/Comment.tsx
import EditableComments from './EditableComments';
function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export default async function Comments() {
  const comments = await getComments();
  return (
    <div>
      <p>评论</p>
      <EditableComments comments={comments} />
      {comments.map((comment) => (
        <p key={comment}>{comment}</p>
      ))}
    </div>
  );
}

Remix Example

Remix uses a loader function to fetch data on the server. By default, the loader blocks rendering until the data resolves. To achieve streaming, Remix provides a defer API that returns promises to the client, combined with <Await /> and <Suspense /> for progressive rendering.

// app/routes/index.tsx
import type { LoaderFunction } from '@remix-run/node';
import { json, defer } from '@remix-run/node';
import { Await, useLoaderData, Suspense } from '@remix-run/react';
function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export const loader: LoaderFunction = async () => {
  const comments = getComments(); // note: no await
  return defer({ comments });
};
export default function Index() {
  const { comments } = useLoaderData<{ comments: Promise<string[]> }>();
  return (
    <div>
      <div>商品</div>
      <p>价格</p>
      <div>
        <p>评论</p>
        <Suspense fallback={<div>Loading...</div>}>
          <Await resolve={comments}>
            {(comments) => comments.map((c) => <p key={c}>{c}</p>)}
          </Await>
        </Suspense>
      </div>
    </div>
  );
}

Custom SSR with Streaming

The article then builds a minimal SSR project from scratch, showing the directory layout and scripts for development. The server uses renderToString initially, then switches to renderToPipeableStream to enable true streaming.

// server/render.js
import React from 'react';
import App from '../src/App';
import HTML from '../src/html';
import { renderToPipeableStream } from 'react-dom/server';
function getComments() {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export default async function render(res) {
  const comments = getComments(); // note: promise, not awaited
  const { pipe } = renderToPipeableStream(
,
    {
      onShellReady() {
        pipe(res);
      },
    }
  );
}

The HTML component is a simple wrapper that outputs the <html> , <head> , and <body> tags. Initially it includes a script tag for the client bundle. Later, to pass server‑fetched data to the client without a full page reload, the article injects the data via a global variable:

// src/html.jsx (injecting data)
export default ({ children, comments }) => {
  return (
    <html>
      <head>
        <link rel="stylesheet" href="/index.css" />
      </head>
      <body>
        <div id='root'>{children}</div>
        <script dangerouslySetInnerHTML={{ __html: `window.__diy_ssr_context=${JSON.stringify(comments)}` }} />
        <script src="/index.js"></script>
      </body>
    </html>
  );
};

On the client side, the entry file reads this global variable and passes it to the root component:

// src/index.js
import React, { startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import App from './App';
startTransition(() => {
  hydrateRoot(document.getElementById('root'),
);
});

Using the Upcoming use Hook

React’s future use hook (RFC 229) allows components to read a promise directly, delegating loading and error handling to the nearest <Suspense> . The article demonstrates a simple demo component that uses use(getSomeData()) to synchronously obtain the resolved value.

import React, { Suspense, use } from 'react';
function getSomeData() {
  return new Promise((resolve) => setTimeout(() => resolve('hello demo'), 3000));
}
export default function Demo() {
  const data = use(getSomeData());
  return (
Title
{data}
);
}
export function DemoWrapper() {
  return (
Loading Demo
}>
);
}

Finally, the article combines the use hook with server‑side streaming to build a fully non‑blocking comment section, showing how the server can send a promise to the client, the client renders a fallback, and once the promise resolves the real content replaces the placeholder without a full page refresh.

The article concludes with a brief discussion of the internal mechanics of React’s streaming implementation, including the hidden <div> segments, the $RC script that swaps placeholders, and the role of boundaries and segments in progressive hydration.

ReactStreamingSSRWeb DevelopmentNext.jsRemixServer Components
Ctrip Technology
Written by

Ctrip Technology

Official Ctrip Technology account, sharing and discussing growth.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.