サーバーサイドレンダリング(SSR)で renderToString() を使うと、ページ全体のレンダリングが終わるまでブラウザに1バイトも返せません。React 18 で追加された renderToPipeableStream() は、できあがった部分から HTML を少しずつ送り出すための API です。この記事では Node.js のサーバーでの基本形、Suspense と組み合わせて遅い部分だけ後から流し込む仕組み、onShellReady と onAllReady の使い分け、エラー時のステータスコードやタイムアウトの扱いまでを解説します。
目次
全部レンダリングし終わるまで待つのをやめる
renderToString() は React のツリーを一度に描画して、完成した HTML 文字列を返します。書き方は簡単ですが、途中経過を返す手段がありません。ページの中に1つでも重いコンポーネントがあると、その処理が終わるまでレスポンスの送信が始まらず、ユーザーのブラウザは真っ白なまま待ち続けることになります。
renderToPipeableStream() は、React のレンダリング結果を Node.js の書き込み可能ストリーム(http.ServerResponse など)へ流し込む API です。ページを「すぐ描けるところ」と「時間がかかるところ」に分け、前者を先に送ってしまいます。ブラウザは受け取った分から順に解析して表示するので、ヘッダーやナビゲーションといった骨組みは早い段階で目に見えるようになります。
この「すぐ描けるところ」を React ではシェル(shell)と呼びます。具体的には、<Suspense> で囲まれていない部分がシェルです。逆に <Suspense> の内側は、データ待ちが発生したらいったん fallback を送っておき、準備ができた時点で本来の中身を追加で流します。つまりストリーミング SSR は、Suspense の境界をどこに置くかで挙動が決まります。
renderToPipeableStream の基本形
まずは動く最小の構成を見てみます。renderToPipeableStream() は react-dom/server から import し、第1引数に React ノード、第2引数にオプションを渡します。この関数はすぐには HTML を返しません。戻り値は pipe と abort を持つオブジェクトで、送信を始めるタイミングは onShellReady コールバックの中で自分で決めます。
import express from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';
const app = express();
// ビルド済みの JS を配信する
app.use('/static', express.static('./dist'));
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
// ハイドレーション用のスクリプト。React が script タグを差し込んでくれる
bootstrapScripts: ['/static/client.js'],
// シェル(Suspense の外側)が描き終わった時点で呼ばれる
onShellReady() {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
// ここから HTML が少しずつ res に流れていく
pipe(res);
},
});
});
app.listen(3000);
渡すコンポーネント側にも決まりがあります。ストリーミング SSR では、<html> から </html> までを React コンポーネントとして書くのが基本の形です。renderToString() のときのように「テンプレート文字列の中に描画結果を埋め込む」書き方をすると、React が <head> の中身や bootstrapScripts のタグを適切な位置に出力できなくなります。
import { Suspense } from 'react';
import { Comments } from './Comments';
export default function App() {
return (
// ドキュメント全体を React で書く
<html lang="ja">
<head>
<meta charSet="utf-8" />
<title>ストリーミング SSR のサンプル</title>
</head>
<body>
<h1>記事タイトル</h1>
<p>ここはシェル。すぐに描けるのでいちばん先に送られる。</p>
{/* 時間がかかる部分だけ Suspense で囲む */}
<Suspense fallback={<p>コメントを読み込み中…</p>}>
<Comments />
</Suspense>
</body>
</html>
);
}
<!doctype html> は React が自動で先頭に付けるため、自分で書く必要はありません。pipe(res) を呼んだ時点で、シェルの HTML が res に書き出され、レンダリングが進むたびに続きが追記されていきます。すべて書き終わると React が自動でストリームを終了するので、res.end() を自分で呼ぶ必要もありません。
戻り値とオプションを整理する
戻り値のオブジェクトが持つのは2つのメソッドだけです。pipe(writable) は指定した書き込み可能ストリームへ出力を流し始めるもので、呼べるのは1回だけです。abort(reason?) はサーバー側のレンダリングを打ち切るためのもので、後述するタイムアウトで使います。
オプションのうち中心になるのがコールバック群です。名前が似ていて混乱しやすいので、呼ばれるタイミングを押さえておきます。
| コールバック | 呼ばれるタイミングと用途 |
|---|---|
onShellReady() | シェル(Suspense の外側)のレンダリングが完了し、送信を開始できる状態になったとき。ここで pipe() を呼ぶ |
onShellError(error) | シェルのレンダリング中にエラーが起きて、シェル自体を出力できないとき。まだ1バイトも送っていないので、代わりのエラーページを返せる |
onAllReady() | Suspense の内側も含め、すべてのレンダリングが完了したとき。クローラー向けや静的生成のように「全部揃ってから返したい」場合にここで pipe() を呼ぶ |
onError(error, errorInfo) | サーバー側でエラーが発生するたびに呼ばれる。Suspense 境界内で復帰できたものも含むので、ログ出力とステータス判定に使う |
残りのオプションは、出力される HTML の内容を調整するものです。よく使うのは bootstrapScripts で、指定した URL が <script async> として HTML に差し込まれます。
| オプション | 説明 |
|---|---|
bootstrapScripts | クライアントで実行する JS の URL 配列。<script async> として出力される |
bootstrapModules | bootstrapScripts と同じだが <script type="module"> として出力される |
bootstrapScriptContent | インラインの <script> として埋め込む文字列。初期データを window に渡す用途などに使う |
identifierPrefix | useId が生成する ID の接頭辞。1ページに複数の React ルートを置くときに衝突を防ぐ |
nonce | Content-Security-Policy の script-src で許可するための nonce 文字列 |
namespaceURI | ストリームのルート名前空間。SVG や MathML を直接出力する場合に指定する |
progressiveChunkSize | 1チャンクあたりのバイト数の目安。通常は既定値のままでよい |
Suspense の内側は後から流し込まれる
ストリーミング SSR のいちばん面白い部分が、遅いコンポーネントの扱いです。<Suspense> の内側でデータ待ちが起きると、React はそこで止まらず、まず fallback を HTML として書き出して先に進みます。そしてデータが揃った時点で、本来の中身を あとから同じレスポンスの続きとして送信します。
すでにブラウザに送った HTML を書き換えることはできないので、React は少し変わった方法をとります。後から届く中身は hidden 属性の付いた要素に入れて送り、直後に置いた小さなインラインスクリプトが、それを fallback の位置へ差し替えます。実際に流れてくる HTML は、おおよそ次のような形です。
<!-- 1. まずシェルと fallback が届く(この時点で画面に出る) -->
<h1>記事タイトル</h1>
<p>ここはシェル。すぐに描けるのでいちばん先に送られる。</p>
<!--$?--><template id="B:0"></template><p>コメントを読み込み中…</p><!--/$-->
<!-- 2. データが揃ったあと、同じレスポンスの続きとして届く -->
<div hidden id="S:0">
<ul><li>1件目のコメント</li><li>2件目のコメント</li></ul>
</div>
<script>$RC("B:0", "S:0")</script>
コメントノードが fallback の範囲を示す目印になっていて、末尾のスクリプトが隠し要素の中身をその位置へ移動させます。この差し替えは JavaScript のバンドルが読み込まれる前でも動きます。インラインの短いスクリプトだけで完結しているためで、ハイドレーションを待たずに実際のコンテンツが表示されるのがストリーミング SSR の利点です。
複数の <Suspense> を置いた場合は、準備できたものから順に流れてきます。したがって Suspense 境界は「一緒に出てほしい単位」で区切るのがコツです。細かく分けすぎると画面が何度もガタつき、大きく囲みすぎると遅いデータ1つのために広い範囲が待たされます。
bootstrapScripts と hydrateRoot でつなぐ
送られた HTML にイベントハンドラーは含まれないので、ブラウザ側で React を結び付けるハイドレーションが必要です。bootstrapScripts に指定した JS が読み込まれ、その中で hydrateRoot() を呼びます。サーバーで <html> から書き出しているので、ハイドレーションの対象も document そのものになります。
import { hydrateRoot } from 'react-dom/client';
import App from './App';
// サーバーで renderToPipeableStream に渡したものと同じツリーを渡す
hydrateRoot(document, <App />);
ストリーミングでは、ハイドレーションもまとめて一度に行われるわけではありません。React はまずシェルをハイドレートし、Suspense 境界の中身は HTML が届いた順にハイドレートしていきます。まだ HTML が届いていない部分があってもクライアント側の React は待たされないため、シェルの操作は早い段階で反応するようになります。
サーバーでデータを取得してクライアントにも同じ値を渡したい場合は、bootstrapScriptContent が使えます。ここに書いた文字列がインラインスクリプトとして HTML に埋め込まれるので、window 経由で初期データを引き渡せます。
const { pipe } = renderToPipeableStream(<App user={user} />, {
bootstrapScripts: ['/static/client.js'],
// クライアントの JS より先に実行されるインラインスクリプト
bootstrapScriptContent: `window.__USER__ = ${JSON.stringify(user).replace(
/</g,
'\\u003c',
)};`,
onShellReady() {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
pipe(res);
},
});
データに </script> という文字列が含まれるとスクリプトタグが途中で閉じてしまうため、< をエスケープしてから埋め込んでいます。クライアント側では window.__USER__ を初期値として使い、サーバーと同じ内容で初回レンダリングされるようにします。
onShellReady と onAllReady はどちらで pipe するか
pipe() をどちらのコールバックで呼ぶかによって、レスポンスの性格が大きく変わります。onShellReady で呼べば段階的な送信になり、onAllReady で呼べば「全部できてから一括で送る」動きになります。同じ API で両方の挙動を選べるのが renderToPipeableStream() の便利なところです。
通常のリクエストは onShellReady
ブラウザからの通常のアクセスでは onShellReady を使います。最初のバイトが届くまでの時間が短くなり、体感速度が上がります。遅いデータは fallback が先に表示され、あとから中身に置き換わります。
クローラーや静的生成には onAllReady
検索エンジンのクローラーや、HTML ファイルとして書き出す静的生成では、fallback のままの HTML が残ると困ります。この場合は onAllReady の中で pipe() を呼びます。すべてのレンダリングが終わってから送信が始まるので、完成した HTML だけが出力されます。段階的な送信の利点は失われますが、内容が欠けないことのほうが重要な場面です。
app.get('/', (req, res) => {
// クローラーかどうかを判定する(判定方法は用途に応じて)
const isCrawler = /bot|crawler|spider/i.test(req.get('user-agent') ?? '');
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/static/client.js'],
onShellReady() {
// クローラー向けは onAllReady まで待つので、ここでは何もしない
if (isCrawler) return;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
pipe(res);
},
onAllReady() {
if (!isCrawler) return;
// Suspense の中身まで揃った完全な HTML を一度に返す
res.setHeader('Content-Type', 'text/html; charset=utf-8');
pipe(res);
},
});
});
onAllReady はストリーミングした場合にも呼ばれます。onShellReady で pipe() 済みのときに onAllReady でもう一度 pipe() を呼ぶことはできないので、上のように排他的に書く必要があります。
エラーが起きたときのステータスコードをどう決めるか
ストリーミングには「送信を始めたらもうステータスコードを変えられない」という制約があります。HTTP のステータス行はレスポンスの先頭にあるからです。そのため、エラーの起き方によって対応が変わります。
シェルが壊れたときは onShellError で 500 を返す
Suspense の外側でエラーが投げられると、シェルを作れないので送信自体が始まりません。このとき呼ばれるのが onShellError です。まだ何も送っていない状態なので、ステータスコードを 500 にして、代わりの静的な HTML を返せます。
送信開始前に起きたエラーも 500 として扱う
Suspense の内側で起きたエラーは、シェルの出力を妨げません。React はその境界の fallback を出したうえでクライアント側の再レンダリングに任せるため、onShellError は呼ばれず onError だけが呼ばれます。ただし、シェルが完成する前に onError が呼ばれていたのなら、そのページは不完全です。フラグを立てておき、onShellReady の時点で判断してステータスコードを 500 にするのが React 公式ドキュメントでも紹介されている書き方です。
app.get('/', (req, res) => {
let didError = false;
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/static/client.js'],
onShellReady() {
// 送信開始前にエラーが起きていたら 500 にする
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
pipe(res);
},
onShellError(error) {
// シェル自体を出力できなかった場合。まだ何も送っていない
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send('<!doctype html><p>ページを表示できませんでした</p>');
},
onError(error) {
didError = true;
console.error(error);
},
});
});
onError は Suspense 境界で復帰できたエラーも含めてすべて通知されます。ログを集約する場所として使い、握りつぶさないようにしておくと原因の追跡が楽になります。React 19 では第2引数に componentStack を含む情報が渡されるので、どのコンポーネントで起きたかを記録できます。
abort でレンダリングを打ち切る
外部 API の応答が返ってこないと、Suspense の内側がいつまでも解決せずレスポンスが閉じられません。これを防ぐのが戻り値の abort() です。呼び出すと、まだ解決していない Suspense 境界のサーバー側レンダリングを諦め、その部分をクライアント側でレンダリングし直すよう指示した状態でストリームを閉じます。fallback が表示されたまま、ブラウザ側で続きが描かれる形になります。
const ABORT_DELAY = 10000; // 10 秒
app.get('/', (req, res) => {
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/static/client.js'],
onShellReady() {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
pipe(res);
},
onError(error) {
console.error(error);
},
});
// 一定時間で打ち切り、残りはクライアント側にレンダリングさせる
const timeout = setTimeout(() => {
abort(new Error('SSR timeout'));
}, ABORT_DELAY);
// 送信し終わったらタイマーを解除する
res.on('close', () => clearTimeout(timeout));
});
abort() に渡した理由は onError に届くので、タイムアウトが実際に発生したかどうかをログから確認できます。シェルすら完成していない段階で abort() を呼んだ場合は onShellError が呼ばれ、エラーページを返す流れになります。タイマーを張りっぱなしにするとプロセスに残り続けるため、レスポンスが閉じたら解除しておくのを忘れないようにします。
Web Streams 環境では renderToReadableStream を使う
renderToPipeableStream() は Node.js のストリーム API に依存しているため、Cloudflare Workers や Vercel のエッジランタイム、Deno のように Web Streams しかない環境では使えません。そうした環境向けに用意されているのが renderToReadableStream() です。やりたいことは同じですが、インターフェースが Promise ベースになります。
| 比較項目 | renderToPipeableStream | renderToReadableStream |
|---|---|---|
| 対象環境 | Node.js のストリーム | Web Streams(エッジ、Deno など) |
| 戻り値 | { pipe, abort } を同期的に返す | ReadableStream に解決する Promise |
| シェルの完了 | onShellReady で通知される | 返された Promise が解決する |
| シェルのエラー | onShellError で通知される | Promise が reject する |
| 全体の完了 | onAllReady で通知される | ストリームの allReady プロパティ(Promise) |
| 中断 | abort() を呼ぶ | signal オプションに AbortSignal を渡す |
import { renderToReadableStream } from 'react-dom/server';
import App from './App';
export default async function handler(request) {
const controller = new AbortController();
setTimeout(() => controller.abort(), 10000);
try {
// await した時点でシェルの準備ができている
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/static/client.js'],
signal: controller.signal,
onError(error) {
console.error(error);
},
});
// クローラー向けに全部待つならここで await stream.allReady;
return new Response(stream, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
} catch (error) {
// シェルのレンダリングに失敗した場合はここに来る
return new Response('<!doctype html><p>ページを表示できませんでした</p>', {
status: 500,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
}
コンポーネント側のコードや Suspense の使い方はまったく同じです。違うのはサーバー側の受け口だけなので、実行環境を移すときも <App /> を書き直す必要はありません。
Next.js を使っているなら自分で呼ぶ場面はない
ここまでの内容は、SSR サーバーを自分で組み立てる場合の話です。Next.js の App Router や Remix のようなフレームワークは、内部でストリーミング SSR の仕組みを使っています。アプリのコードに renderToPipeableStream() を直接書くことはまずなく、書くのは <Suspense> の境界と、その中に置くコンポーネントです。
それでも仕組みを知っておくと、フレームワーク側の挙動が理解しやすくなります。Next.js で loading.tsx を置くとページ全体を <Suspense> で囲んだのと同じ効果になるのも、ページの一部だけが先に表示されるのも、この記事で見たシェルと Suspense 境界の関係そのものです。「なぜここは早く出て、ここは遅れて出るのか」を説明できるようになると、境界の置き場所を意図して決められるようになります。
まとめ
renderToPipeableStream(reactNode, options?) は、React のツリーを Node.js のストリームへ段階的に書き出す SSR 用の API です。戻り値の pipe() で送信を始め、abort() で打ち切ります。<Suspense> で囲まれていない部分がシェルで、これが描き終わると onShellReady が呼ばれるので、その中で pipe(res) を呼ぶとストリーミングが始まります。Suspense の内側は fallback が先に送られ、データが揃った時点で隠し要素とインラインスクリプトによって差し替えられるため、JavaScript の読み込みを待たずに本来の内容が表示されます。クローラー向けや静的生成のように完成した HTML が必要な場面では、onAllReady の中で pipe() を呼んで全体の完了を待ちます。エラー処理は、シェルを出力できなかった onShellError で 500 を返しつつ、onError でフラグを立てて onShellReady のステータスコード判定に使うのが定石です。クライアント側は bootstrapScripts で読み込んだ JS の中で hydrateRoot(document, <App />) を呼びます。Web Streams しかない環境では、同じ考え方を Promise ベースにした renderToReadableStream() を使ってください。