皆さんこんにちは。これは株式会社カオナビ Advent Calendar 2025の22日目(シリーズ3)の記事です。
今月は、RSC (React Server Components)の脆弱性が話題になりましたね。
これにより、世間ではRSCへの不信感が増大しているようです。そのため、脱RSC、さらに過激な人は脱Reactといった動きが主張されています。
いきなりまとめ
しかし実は、脆弱性が怖い人でもRSCを活用できます。サーバーを立てなければいいのです。
名前から誤解されがちですが(というか誤解を生む名前ですが)、サーバーを立てなくてもRSCを活用できます。今回のような任意コード実行といった脆弱性は、サーバーを立ててReactのサーバー用ランタイムを動かすのをやめれば、リスクを根本的に回避できます。
サーバーを立てないRSCは、アプリの最適化(部分的なプリレンダリング)の役割を果たします。古き良き、ViteでビルドしてHTML+CSS+JSをサーバーに置いておくようなSPAの延長線上に、「RSCを活用したSPA」があります。
もちろん、サーバーを立てないRSCは、RSCの恩恵の一部しか受けられません。しかし、一部は受けることができます。
そもそも、RSCは(他の側面もありつつ)Reactアプリのパフォーマンスを向上するための技術です。もしあなたが次のアプリでまだReactを(サーバーの関わらないSPAとしてでも)使う気があるのであれば、RSCを活用しないのは損かもしれません。
サーバーを立てないRSC
RSCのアーキテクチャは、1つのReactアプリのレンダリングが2段階に分かれていることがポイントで、1段階目が「サーバー側」、2段階目が「クライアント側」となります。RSCには「サーバーコンポーネント」「クライアントコンポーネント」という分けがありますが、これはコンポーネントが1段階目と2段階目のどちらでレンダリングされるかを表す分類です。
詳しくは筆者が以前に書いたこちらの記事もご覧ください。
2段階目(クライアントコンポーネント)は、クライアント、すなわちユーザーのブラウザの中で動きます1。
それに対して、1段階目(サーバーコンポーネント)が、Webサーバー上で動くというのがRSCの基本的な使い方の一つですが、実際には、1段階目が動くのは2段階目より前ならいつでもいいのです。そのため、ビルド時に1段階目を動かすことで、サーバーを立てなくてもRSCの1段階目を動かすことができます。
Next.jsなどのフレームワークでは1段階目に対して最適化が入っており、「ビルド時に動かせるものはビルド時に1段階目を動かして、リクエスト時に動かす必要があるものはリクエスト時にサーバー上で動かす」といった挙動になります(このほかにキャッシュによる動作タイミングの最適化もあります)。
「サーバーコンポーネントの中でDBにアクセスする」といった動きをしたい場合は当然サーバーが必要になりますが、逆に言えば、そういう使い方をしなければビルド時にサーバーコンポーネントを動かせば事足ります。これがサーバーを立てないRSCの使い方です。
サーバーを立てないRSCのメリット
では、サーバーを立てないのにRSCを使うことは、どんなメリットがあるのでしょうか。ここでは、従来(この記事ではRSC導入前のことを従来と呼びます)のReactアプリケーションと比較します。特に、サーバーを持たないものを比較対象とします。
一言で言うと、メリットはパフォーマンスです。特に、従来のReactアプリでは、アプリケーションの初期表示のためにReactアプリケーション全体をユーザーのブラウザ上で実行する必要がありました。事前にHTMLを出力しておくSSG (Static Site Generation) の場合でも、ハイドレーションのためにReactアプリ全体を実行しなければならないのは同じです。
RSCでは、「必ずしもわざわざブラウザ上で実行する必要がなく、ビルド時に実行しておけばそれでいいコード」を1段階目(サーバーコンポーネント)にします。
そうすることで、2段階目(ブラウザ上)で実行しなければならないJavaScriptコードの量が減少します。
また、場合によりけりですが、1段階目でのみ大きなライブラリに依存している場合などは、バンドルサイズも減少する可能性があります。
フレームワークを用いてサーバーを立てないRSCを実践する
React公式ドキュメントにもあるように、RSCはフレームワークを介して使うのがよいです。そこで、今回はReactフレームワークのひとつである Waku を用いてサーバーの無いRSCを実践してみます。
Wakuの公式ドキュメントに従って進めましょう。以下のコマンドでWakuプロジェクトのスターターキットを用意できます。
npm create waku@latest
これでWakuプロジェクトができて、その中でnpm run devをするととりあえずWakuが動きますね。
今回の検証では、記事執筆時点の最新バージョンであるWaku 0.27.5を使用しています。
次に、公式ドキュメントによると、以下の内容のsrc/server-entry.tsを用意することで完全なSSGモードにできます。
import { fsRouter } from 'waku';
import adapter from 'waku/adapters/netlify';
export default adapter(
fsRouter(import.meta.glob('./**/*.{tsx,ts}', { base: './pages' })),
{ static: true },
);
このファイルを用意した状態でnpm run buildを実行すると、dist/public内に以下のように結果が出力されます。
index.htmlがあることから、サーバーいらずのウェブサイトが出力されていそうです。実際に、これを適当な(単にファイルを配信するだけの)HTTPサーバーを起動して開いてみると、Reactアプリケーションが動きます。useStateを用いたコンポーネントも動いているので、ちゃんとReactがブラウザ上で動いていることが分かりますね。まさにこれは、(SSGされているとはいえ)古き良きReactアプリの動きです。
また、WakuのスターターキットはすでにRSC導入済みの状態になっています。つまりこれで、サーバー無しの、かつRSCを活用したReactアプリケーションが動いたわけです。とても簡単ですね。
ちなみに、出力されたindex.htmlにはこのような部分が含まれています。
<script>(self.__FLIGHT_DATA||=[]).push("1:I[\"6d786e16fc6b\",[],\"ErrorBoundary\",1]\n2:I[\"847a2b1045ef\",[],\"Children\",1]\n...(省略)</script>
これがいわゆるFlightプロトコルのデータで、1段階目のレンダリングが済んだあと、2段階目のレンダリングがされる前の中間データです。このデータがクライアント用のReactランタイムに渡されることで、2段階目のレンダリングが走ります(今回の場合はハイドレーションが行われます)。
これを見ると、確かにビルド時に1段階目のレンダリングが行われていることが確かめられます。
RSCの恩恵を受ける
では、Wakuのスターターキットをちょっといじって、RSCの恩恵を受けてみましょう。
典型的な例ですが、サーバーコンポーネントでMarkdownをHTMLに変換してみます。
import { marked } from 'marked';
export const RenderMarkdown: React.FC<{ markdown: string }> = ({ markdown }) => {
const contentHtml = marked.parse(markdown);
return <div dangerouslySetInnerHTML={{ __html: contentHtml }} />;
};
export default async function AboutPage() {
const data = await getData();
return (
<div>
<title>{data.title}</title>
<RenderMarkdown markdown={data.markdown} />
<Link to="/" className="mt-4 inline-block underline">
Return home
</Link>
</div>
);
}
const getData = async () => {
const markdown = await readFile('src/content/about.md', 'utf-8');
return {
title: 'About',
markdown,
};
};
src/content/about.mdからMarkdownファイルを読み込んで、それをmarkedを使ってHTMLに変換してcontentHtmlとします。
AboutPageおよびRenderMarkdownはサーバーコンポーネントであり、MarkdownをdangerouslySetInnerHTMLを用いて描画します。
これを実際に動作させてみると、期待通りに動作します。Markdownコンテンツの中身が表示されています。
自分で試してみたい方は以下のリポジトリを参照してみてください。
そして、ビルド結果も見てみましょう。about.tsxのビルド結果がdist/public/pages/about/index.htmlに出力されています。SSGの結果としてMarkdownの中身がHTMLとして出力されているのが見て取れますが、Flightプロトコル部分をよく見ると以下のような記述が見つかります。
[\"$\",\"div\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"<h1>About Waku</h1>\\n<p>Waku is <strong>the minimal React framework</strong>.</p>\\n<h2>Features</h2>\\n<ul>\\n<li>React Server Components</li>\\n<li>Static Site Generation</li>\\n<li>File-based routing</li>\\n</ul>\\n<h2>Getting Started</h2>\\n<p>Visit the <a href=\\\"https://waku.gg\\\">official documentation</a> to learn more.</p>\\n\"}}]
つまり、第1段階のレンダリングが済んだ時点でmarkdownのレンダリングが終わっており、dangerouslySetInnerHTML propに渡されるHTML文字列の形で出力されていることが分かります。
これが意味するところは、ブラウザ上ではmarkdownをパースしてHTMLに変換する処理は走らないということです。これにより、以下のメリットが実現できています。
- バンドルサイズの削減(
markedがバンドルに含まれない) - ブラウザ上で実行する処理の削減(markdownをパースしてHTMLに変換する処理が行われない)
このように、必ずしもブラウザ上で行う必要がない処理はサーバーコンポーネントに逃して、ブラウザ上では本当に必要な処理(クライアントコンポーネント)だけ行うのがRSCの考え方です。
パフォーマンスを比較する
では、Markdownの変換処理をサーバーコンポーネントで行う場合とクライアントコンポーネントで行う場合で、パフォーマンスを比較してみましょう。今回のサンプルは小さいので、以下で示すデータは違いを強調するためにCPUを20x slowdownにした状態で計測しています。
まず、サーバーコンポーネントで行う場合でこのページを読み込んで、JavaScriptの実行が完了するまで(=ハイドレーションが完了するまで)の時間を計測します。
画像の通り、JavaScriptの実行に約400ミリ秒ほどかかっています。
では、クライアントコンポーネントで行う場合を見てみましょう。RenderMarkdownをクライアントコンポーネントにすることで、先ほど説明したRSCのメリットを打ち消します。
"use client";
import { marked } from 'marked';
export const RenderMarkdown: React.FC<{ markdown: string }> = ({ markdown }) => {
const contentHtml = marked.parse(markdown);
return <div dangerouslySetInnerHTML={{ __html: contentHtml }} />;
};
こうすると、マークダウンのパース・HTMLへの変換処理がクライアント側(ブラウザ上)に移動し、ブラウザ上で実行するJavaScriptが増えるはずです。
これが実際の結果です。画像を見ると、上の画像に比べても違いが分かりやすいですね。一際大きいJavaScriptの処理が増えており、処理時間が約400ミリ秒ほど増えています。これはCPU 20x slowdownなので、単純計算で本来は20ミリ秒の増加となります。この小さいサンプルで、しかも開発機のMacでこの差ですから、なかなか無視できない差ですね。
ちなみに、この場合Flightプロトコルの中身はどうなって言えるかというと、このような記述が見つかります。
[\"$\",\"$L5\",null,{\"markdown\":\"# About Waku\\n\\nWaku is **the minimal React framework**.\\n\\n## Features\\n\\n- React Server Components\\n- Static Site Generation\\n- File-based routing\\n\\n## Getting Started\\n\\nVisit the [official documentation](https://waku.gg) to learn more.\\n\"}]
つまり、HTMLに変換される前のMarkdownの状態で第1段階のレンダリングが終了していることが分かります。これがクライアントコンポーネント(RenderMarkdown)に渡されて、第2段階のレンダリングでmarkdownの変換処理が行われるわけです。
バンドルサイズも比較する
Markdownの処理をサーバーコンポーネントとクライアントコンポーネントで行う場合とで、バンドルサイズを比較します。
サーバーコンポーネントで行う場合:
% ls -lh dist/public/assets | awk 'NR>1 {print $5 "\t" $9}'
6.2K _layout-Cyi_UJ3t.css
206B client-nVYuH_h7.js
950B index-C1fkTsNx.js
227K index-CX4-zueH.js
クライアントコンポーネントで行う場合:
% ls -lh dist/public/assets | awk 'NR>1 {print $5 "\t" $9}'
6.2K _layout-Cyi_UJ3t.css
39K about-CHYrEy57.js
206B client-DYGFdGIZ.js
313B compiler-runtime-Cft6pUNS.js
227K index-CSa6kZsM.js
737B index-D51AZt2w.js
顕著な違いは39KBのabout-CHYrEy57.jsであり、これがおおよそmarkedの容量に相当します。サーバーコンポーネントに処理を移したことで、この容量を削減できたことになります。
実は……Next.jsでもできる
ここまでWakuを用いて説明したので、「なるほど、サーバーを立てないRSCを使うためにはWakuが必要なのか」と思われた方もいるかもしれません。
しかし、実はNext.jsでもできます。Next.jsではStatic Exportsとして、RSCを活用しつつサーバーに依存しないSPAを出力する機能が提供されています。
しっかりとガイドも書かれています。色々とStatic Exportsでは「非対応の機能」も並べられていますが、サーバーを使わないことを考えると必然であり、サーバーを立てないSPAを作るにはこれでも十分です。
このように、Reactのフレームワークでは、サーバーに依存しない構成を選べるのは結構普通の機能なのかもしれません。
おわりに
この記事では、RSCの脆弱性を受けてReactでサーバーを使いたくないと思った人に向けて、実はRSCをやめる必要はないし、何ならNext.jsをやめる必要すらないことをお伝えしました。
そもそもサーバーを立てないことで類似のリスクに根本的に対処しつつ、React製SPAのパフォーマンス向上のためにRSCを活用することができます。
アプリケーションの設計としては、サーバーがあるRSCとは考え方を多少変えないといけませんが、ReactでSPAを作るなら、依然としてRSCは有用な技術です。
RSCに関しては「サーバーとクライアントの境界が曖昧になる」といった批判もありますが、この記事のように「ブラウザで動かす必要がないところを事前にプリレンダリングするためのもの」と割り切れば、サーバーコンポーネントとクライアントコンポーネントの分け方もわりと明確になります。境界が曖昧なことによるデメリットも、サーバーが無いのですから小さいです。
ただ、現状のReactフレームワークはこのような使い方ができるとはいえ、やはりサーバーを立てる部分をフィーチャーしがちです。ファイルシステムルーティングとかも自動で付いてきますしね。今回にわかに出現した脆弱性を不安に思う人たちというターゲット層に向けて、最初からサーバーがないことを前提にRSCを活用するフレームワークとかが出てきても面白いかもしれません。
-
厳密には、SSR (Server-Side Rendering) をする場合はWebサーバー上でも2段階目が動きます。これは、従来(RSCがない時代)のSSRでReactアプリケーションがWebサーバー上で実行されていたのと同じことです。 ↩



