【React】ViteでのストリーミングSSR/Suspenseサンプル
更新日:2026/02/04
ReactのrenderToPipeableStream()は、コンポーネントのレンダリング結果をNode.jsストリームに出力する関数です。
前回、ReactでのSSR(サーバーサイドレンダリング)/CSR(クライアントサイドレンダリング)サンプルを作成しました。
■【React】viteでのSSR/CRSサンプル
この記事ではrenderToPipeableStream()の機能を活かしていなかったので、今回は本来のストリーミング目的で使用するサンプルを作成しました。
renderToPipeableStream()とSuspenseコンポーネントはセットで使用する
ストリーミングには、大量だったり準備に時間がかかったりするデータを、準備ができたものから少しずつ送信することで、ユーザー側の待機時間を減らすイメージがあります。
renderToPipeableStream()は準備に時間がかかったとしても、一通りのレンダリングが終わるまで配信しません。
ですが、Suspenseコンポーネントの子コンポーネントに非同期コンポーネントが含まれる場合、非同期コンポーネントのレンダリングをスキップさせます。そうすることで暫定的にレンダリングを終わらせてストリームに出力します。
非同期コンポーネントの結果が出たら、ストリームに出力され、ブラウザ側でDOMが置換されます。
※置換を行うスクリプトもストリームで送信されます。
このように、renderToPipeableStream()はSuspenseコンポーネントとセットで使用することで、本来の機能を活かすことができます。
const { pipe } = renderToPipeableStream(
<div>
<Suspense fallback={<p className="loading">ヘッダー読み込み中...</p>}>
<Ssr text="■■ヘッダー■■" mSecond={5000} />
</Suspense>
<div id="csr">
<Csr />
</div>
<Suspense fallback={<p className="loading">フッター読み込み中...</p>}>
<Ssr text="■■フッター■■" mSecond={3000} />
</Suspense>
</div>,
{
https://note.affi-sapo-sv.com/demo/vite-react-ssr-stream/
■GiyHub
https://github.com/kchan-p/vite-react-ssr-stream
開発環境の準備
今回もViteで開発環境を構築します。
npm create vite@latest vite-react-ssr-stream -- --template react-compiler
フレームワークとしてexpressを使用します。
また、SSRビルド時に静的ファイルをコピーしたいので、vite-plugin-static-copyをインストールします。
npm express --save-dev npm vite-plugin-static-copy --save-dev
ファイル構成
完成後のファイル構成は次のようになります。
vite-react-ssr-stream ┣━ dist ┃ ┣━ csr ←CSR側ビルド結果 ┃ ┃ ┣━ assets ┃ ┃ ┃ ┣━ index-CK43AXXj.css ┃ ┃ ┃ ┗━ index-CTuOo48z.js ┃ ┃ ┣━ src ┃ ┃ ┃ ┗━ csr ┃ ┃ ┃ ┗━ csr.html ←使用しないファイル ┃ ┃ ┗━ manifest.json ←assets内ファイルの名前解決用 ┃ ┗━ ssr ←SSR側ビルド結果 ┃ ┣━ entry-ssr.js ┃ ┣━ template.css ←静的コピー ┃ ┗━ template.html ←静的コピー ┣━ src ┃ ┣━ csr ←CSR側ソース ┃ ┃ ┣━ csr.css ┃ ┃ ┣━ csr.html ←CSRビルド用html ┃ ┃ ┣━ csr.jsx ┃ ┃ ┗━ entry-csr.jsx ┃ ┗━ ssr ←SSR側ソース ┃ ┣━ entry-ssr.jsx ┃ ┣━ ssr.jsx ┃ ┣━ template.css ┃ ┗━ template.html ┣━ package.json ┣━ server.js ←サーバースクリプト ┗━ vite.config.js
フォルダ名にssrとcsrを使用していますが、実際の運用ではserverとclientの方が好まれるかもしれません。
package.json
package.jsonで変更したのは、scriptsのみ。
package.json
{
"name": "vite-react-ssr-stream",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "set NODE_ENV=development&& node server.js",
"build": "vite build && vite build --ssr",
"start": "node server.js"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"express": "^5.2.1",
"globals": "^16.5.0",
"vite": "^7.2.4",
"vite-plugin-static-copy": "^3.2.0"
}
}
vite.config.js
vite.config.jsは次のようになっています。
vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteStaticCopy } from "vite-plugin-static-copy";
// https://vite.dev/config/
export default defineConfig(({ command, isSsrBuild }) => {
const isDev = command === "serve";
return {
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
viteStaticCopy({
targets: [
{
src: [
"src/ssr/template.html",
"src/ssr/template.css"
],
dest: "",
},
],
environment: "ssr"
}),
],
esbuild: {
jsxDev: false,
},
build: isSsrBuild
? {
ssr: true,
outDir: "dist/ssr",
rollupOptions: {
input: "src/ssr/entry-ssr.jsx",
output: {
format: "esm",
},
},
}
: {
// CSR ビルド
outDir: "dist/csr",
manifest: "manifest.json",
rollupOptions: {
input: "src/csr/csr.html",
output: {
format: "esm",
},
},
},
ssr: isDev
? {
// 開発時 SSR
external: ["react", "react-dom"],
}
: {
// 本番 SSR ビルド
// → react / react-dom をバンドルに含める
noExternal: [
"react",
"react-dom",
],
},
}
});
CSR側のビルド時にmanifest.jsonを出力しています。
出力したmanifest.jsonは、本番環境のserver.jsで読み込まれます。
SSRビルド時に静的ファイルをvite-plugin-static-copyを使って出力フォルダにコピーしています。
苦労したのがvite-plugin-static-copyがデフォルトではSSRで動作しない点。
タイミングいいことに、この記事を作成する一週間前のリリースで、environmentオプションに "ssr"を指定すると、SSRで動作するようになりました。
■【Vite】SSRビルド時の静的ファイルコピー
また、SSR側の開発時とビルド時で異なる依存関係を変更しています。
これについては、次のリンク先を参照してください。
SSR側コード
SSR側コードは、server.jsと/src/ssr/内のファイルです。
server.js
server.jsは、サーバー上で常駐してブラウザからのリクエストを処理します。
import express from "express";
import fs from "fs";
import path from "path";
import { pathToFileURL } from "url";
process.env.NODE_ENV ??= "production";
const isProd = process.env.NODE_ENV === "production";
const app = express();
const root = process.cwd();
// template.htmlを読み込みにtemplate.cssの内容を挿入して返す
const loadTemplate = (root, isProd) => {
const templatePath = path.join(
root, isProd ? "dist" : "src", "ssr/template");
const template = fs.readFileSync(templatePath + ".html", "utf-8");
const css = fs.readFileSync(templatePath + ".css", "utf-8");
return template.replace("<!--template.css-->", `<style>${css}</style>`);
};
const render = await (async () => {
const SCRIPT_PRD = "<!--scripts1-->";
const SCRIPT_DEV = "<!--scripts2-->";
const STYLES = "<!--styles-->";
if (isProd) {
/**
* 本番環境
*/
app.use(
"/assets",
express.static(path.join(root, "dist/csr/assets"))
);
// manifest.jsonからビルド後のスクリプトファイル名を取得
const manifest = JSON.parse(
fs.readFileSync("dist/csr/manifest.json")
)["src/csr/csr.html"];
// テンプレートにscriptタグ、linkタグ挿入
const template = loadTemplate(root, isProd)
.replace(SCRIPT_PRD, `<script type="module" crossorigin src="/${manifest.file}"></script>`)
.replace(SCRIPT_DEV, "")
.replace(STYLES, manifest["css"].map(
css => `<link rel="stylesheet" crossorigin href="/${css}">`
).join(""));
// SSR側モジュール読み込み
const importModule = await import(
pathToFileURL(path.join(root, "dist/ssr/entry-ssr.js")).href
);
// レンダー関数取得
const render = (res) => {
return importModule.default(res, template);
};
return render;
}
/**
* 開発環境
*/
const { createServer } = await import("vite");
// vite開発サーバー起動
const vite = await createServer({
server: { middlewareMode: true },
appType: "custom",
});
app.use(vite.middlewares);
// テンプレートにscriptタグ、linkタグ挿入
const template = async (url) => {
const rawTemplate = loadTemplate(root, isProd)
.replace(SCRIPT_PRD, "")
.replace(SCRIPT_DEV, `<script type="module" src="/src/csr/entry-csr.jsx"></script>`)
.replace(STYLES, "");
return await vite.transformIndexHtml(url, rawTemplate);
};
// レンダー関数定義
const render = async (res, url) => {
// SSR側モジュール読み込み
const importModule = await vite.ssrLoadModule("/src/ssr/entry-ssr.jsx");
// レンダー実行
importModule.default(res, await template(url));
};
return render;
})();
app.use(async (req, res) => {
try {
render(res, req.originalUrl);
} catch (e) {
console.error(e);
res.status(500).end("Internal Server Error");
}
});
app.listen(3000, () => {
console.log(`Server running: http://localhost:3000`);
});
大枠はexpressのapp.use()でSSR側をレンダリングして返しています。
expressについては、他のネット記事を参照してください。
ここでは開発時にviteの開発サーバーを起動しています。
開発サーバーを起動することでJSXのリアルタイム変換とCSR側のコード変更監視を行います。
template.html
template.htmlはserver.jsで読み込まれ、renderToPipeableStream()(entry-ssr.jsx)で送信されます。
/src/ssr/template.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>Vite React Streaming SSR / Suspense</title>
<!--template.css-->
<!--scripts1-->
<!--styles-->
</head>
<body>
<h1>Vite React Streaming SSR / Suspense</h1>
<!--ssr-->
<!--scripts2-->
</body>
</html>
<!--styles-->は、template.cssの内容で置換されます。
<!--scripts1-->と<!--styles-->は本番時に、SSRビルドで生成されたスクリプトとcssを読み込むタグと置換されます。
<!--scripts2-->は、開発時のスクリプト読み込みタグと置換されます。
<!--ssr-->は、entry-ssr.jsxで前方パートと後方パートの二つに分割する目印として使用します。
template.css
template.cssはserver.jsでtemplate.htmlの<!--styles-->と置換されます。
/src/ssr/template.css
.loading:before {
content: "";
width: 10px;
height: 10px;
border: 2px solid aqua;
border-radius: 50%;
border-top-color: brown;
border-bottom-color: brown;
animation: spin 1s ease-in-out infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
ここでは、ローディング中の視覚効果を設定しています。
entry-ssr.jsx
entry-ssr.jsxはSSR側のレンダーを行う関数です。
renderToPipeableStream()でコンポーネントのレンダー結果を配信しています。
/src/ssr/entry-ssr.jsx
import { renderToPipeableStream } from "react-dom/server";
import { Suspense } from 'react';
import Ssr from "./ssr";
import Csr from "../csr/csr";
function render(res, template) {
const [head, tail] = template.split("<!--ssr-->");
let didError = false;
const { pipe } = renderToPipeableStream(
<div>
<Suspense fallback={<p className="loading">ヘッダー読み込み中...</p>}>
<Ssr text="■■ヘッダー■■" mSecond={5000} />
</Suspense>
<div id="csr">
<Csr />
</div>
<Suspense fallback={<p className="loading">フッター読み込み中...</p>}>
<Ssr text="■■フッター■■" mSecond={3000} />
</Suspense>
</div>,
{
onShellReady() {
res.status(didError ? 500 : 200);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.write(head);
pipe(res);
res.write(tail);
},
onError(err) {
didError = true;
console.error(err);
},
}
);
}
export default render
まずは、スクリプトとcssの読み込みタグ挿入済みのテンプレート(template.html)を"<!--ssr-->"で前方パートと後方パートに分割します。
renderToPipeableStream()の第一引数で指定したコンポーネントがレンダリングされるとonShellReady()が呼び出されます。
onShellReady()は、テンプレートの前方パートをレスポンスとして出力します。
次にrenderToPipeableStream()の出力をパイプすると、シェル(shell)が出力されます。
最後にテンプレートの後方パートをレスポンスとして出力します。
今回指定したコンポーネントはSuspenseコンポーネントを使用しているので、fallbackで指定したコンポーネントが子コンポーネントの代わりにレンダリングされます。
その後、子コンポーネントのレンダリングが終わったら、fallbackと置き換えられます。
つまり、fallbackが表示されずに最終結果が表示されます。
必ず<div>...</div>などでラップしましょう。
知らないと解決不可能な、ハマりポイントです。
bootstrapScripts:指定した文字列をsrcのURLとしてscriptタグを生成します。
bootstrapModules:bootstrapScriptsの動作 + type="module" を含めます。
bootstrapModules使用例
const { pipe } = renderToPipeableStream(
<Api />,
{
bootstrapModules:"script.js"
server.jsでtemplate.htmlにscriptタグを出力していますが、このオプションに代替できそうですね。
しかし代替すると開発モード時に、次のようなエラーが出力されます。
『@vitejs/plugin-react can't detect preamble. Something is wrong.』
preambleはコード変更を反映するための仕組み。
今回はvite.transformIndexHtml()でtemplate.htmlで仕組みが挿入されます。
問題なのが、bootstrapModulesを使用するとasyncが付加されたscriptタグが出力される点です。
// 本来ならこちら
<script type="module" src="/src/csr/entry-csr.jsx"></script>
// bootstrapModules使用すると...
<script type="module" src="/src/csr/entry-csr.jsx" id="_R_" async=""></script>
その結果、preambleとスクリプトの動作タイミングが狂って「何かがおかしいよ?」エラーが出ます。
ViteとbootstrapModulesオプションは相性が悪いようです。
少なくても開発モードではbootstrapModulesを使用しないようにしましょう。
ssr.jsx
ssr.jsxは、setTimeout()で遅延をおこなう非同期コンポーネントです。
ssr.jsx
async function Ssr({text,mSecond}) {
await new Promise(r=>setTimeout(()=>r(),mSecond));
return (
<div className="ssr">
<p>{text}</p>
</div>
);
}
export default Ssr
非同期コンポーネントなら処理内でawaitで待たなくても、Suspenseが有効になります。
CSR側コード
CSR側コードは、/src/csr内のファイルです。
entry-csr.jsx
entry-csr.jsxは、hydrateRoot()でルートを生成しています。
entry-csr.jsx
import { hydrateRoot } from 'react-dom/client'
import './csr.css'
import Csr from './csr.jsx'
const container = document.getElementById("csr");
if (container) {
hydrateRoot(container,<Csr />);
}
csr.jsx
csr.jsxは、ボタンが押されたら現在のURLのソースコードを取得して表示します。
csr.jsx
import { useState } from "react";
function Csr() {
const [sourceText, setSourceText] = useState("Clickでソースコードを取得");
const [busy, setBusy] = useState(false);
const fetchData = async () => {
setBusy(true);
const location = `${window.location.href}`;
try {
const html = await fetch(location)
.then(data => data.text());
setSourceText(html);
} catch (error) {
setSourceText('Error:', error);
}
setBusy(false);
};
return (
<div id="client">
<p>CSR側</p>
<p><button className={busy ? "loading" : ""} onClick={
() => {
fetchData();
}
}>Click!!</button></p>
<code style={{ whiteSpace: "pre" }}>
{sourceText}
</code>
</div>
);
}
export default Csr
csr.css
csr.cssは、CSR側のスタイル定義です。
ビルド時に他のスタイル定義(あるなら)とひとまとめにして出力されます。
#client{
border-top:1px solid black ;
border-bottom:1px solid black ;
}
csr.html
CSR側ビルドの基点となるhtmlです。
ビルド後にdistフォルダに出力されますが、使用しません。
csr.html
<!doctype html>
<html>
<body>
<div id="root"></div>
<script type="module" src="/src/csr/entry-csr.jsx"></script>
</body>
</html>
テスト
次のコマンドで、動作テストを行えます。
npm run dev
次のメッセージが表示されたら、ブラウザでhttp://localhost:3000にアクセスしましょう。
> vite-react-ssr-stream@0.0.0 dev
> set NODE_ENV=development&& node server.js
Server running: http://localhost:3000
うまく画面が表示されたら成功です。
ビルド
ビルドは次のコマンドを実行します。
npm run build
本番環境で必要なファイルは、ビルドの成果物であるdistフォルダ一式と、server.jsです。
サーバー側ディレクトリ ┣━ dist ┃ ┣━ csr ←CSR側ビルド結果 ┃ ┃ ┣━ assets ┃ ┃ ┃ ┣━ index-CK43AXXj.css ┃ ┃ ┃ ┗━ index-CTuOo48z.js ┃ ┃ ┣━ src ┃ ┃ ┃ ┗━ csr ┃ ┃ ┃ ┗━ csr.html ←使用しないファイル ┃ ┃ ┗━ manifest.json ←assets内ファイルの名前解決用 ┃ ┗━ ssr ←SSR側ビルド結果 ┃ ┣━ entry-ssr.js ┃ ┣━ template.css ←静的コピー ┃ ┗━ template.html ←静的コピー ┗━server.js ← サーバー常駐コード
さらにexpressが必要です。
npm install express --save-dev
サーバーの準備ができたら、server.jsをnode.jsで実行しましょう。
node server.js
備考:renderToPipeableStream()で生成されたhtml
最後にrenderToPipeableStream()で生成されたhtmlを紹介します。
これを見ると、後からコンポーネントを置き換える仕組みが一目瞭然です。
※見やすいように、改行を入れてあります。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>Vite React Streaming SSR / Suspense</title>
<style>.loading:before {
content: "";
width: 10px;
height: 10px;
border: 2px solid aqua;
border-radius: 50%;
border-top-color: brown;
border-bottom-color: brown;
animation: spin 1s ease-in-out infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }</style>
<script type="module" crossorigin src="/assets/csr-a1dNP5Qc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/csr-CK43AXXj.css">
</head>
<body>
<h1>Vite React Streaming SSR / Suspense</h1>
<div>
<!--$?-->
<template id="B:0"></template>
<p class="loading">ヘッダー読み込み中...</p>
<!--/$-->
<div id="csr">
<div id="client">
<p>CSR側</p>
<p><button class="">Click!!</button></p>
<code style="white-space:pre">Clickでソースコードを取得</code>
</div></div>
<!--$?-->
<template id="B:1"></template>
<p class="loading">フッター読み込み中...</p>
<!--/$-->
</div>
<script>requestAnimationFrame(function(){$RT=performance.now()});</script>
</body>
</html>
<div hidden id="S:1"><div class="ssr"><p>■■フッター■■</p></div></div>
<script>
$RB=[];$RV=function(a){$RT=performance.now();for(var b=0;b<a.length;b+=2){var c=a[b],e=a[b+1];null!==e.parentNode&&e.parentNode.removeChild(e);var f=c.parentNode;if(f){var g=c.previousSibling,h=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d||"/&"===d)if(0===h)break;else h--;else"$"!==d&&"$?"!==d&&"$~"!==d&&"$!"!==d&&"&"!==d||h++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;e.firstChild;)f.insertBefore(e.firstChild,c);g.data="$";g._reactRetry&&requestAnimationFrame(g._reactRetry)}}a.length=0};
$RC=function(a,b){if(b=document.getElementById(b))(a=document.getElementById(a))?(a.previousSibling.data="$~",$RB.push(a,b),2===$RB.length&&("number"!==typeof $RT?requestAnimationFrame($RV.bind(null,$RB)):(a=performance.now(),setTimeout($RV.bind(null,$RB),2300>a&&2E3<a?2300-a:$RT+300-a)))):b.parentNode.removeChild(b)};$RC("B:1","S:1")</script>
<div hidden id="S:0"><div class="ssr"><p>■■ヘッダー■■</p></div></div>
<script>$RC("B:0","S:0")</script>
更新日:2026/02/04
関連記事
スポンサーリンク
記事の内容について

こんにちはけーちゃんです。
説明するのって難しいですね。
「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。
裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。
掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。
ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php
このサイトは、リンクフリーです。大歓迎です。

