この記事は万人向けではないかもしれません。内容を理解するには、少なくともフロントエンド開発者またはフルスタック開発者の卵であり、SSRSSGISR といった概念を基本的に理解し、Next.js を少なくとも一度は使ったことがある必要があります。

はじめに

もしかすると、私は生まれつき物事の根本原理に強い好奇心があり、それに加えて、なぜそうしてはいけないのかを納得できるまで問い続けてしまうことがあるからなのかもしれません。たぶんそのおかげで、ときどき独自のアイデアを思いつけるのでしょう。これもその一つです。この発想が浮かんだのは早くも2025年11月のことで、当時は身近な友人たちともいろいろ話しましたが、抱えていたプロジェクトとの日程が重なり、実際に最初の版を作り始めたのは 2026 年の年明け前後になってからでした。その後、また棚上げにしてしまいました——ウェブサイト作りに取りかかったからです。ブログもまだ完全にはできていませんが、ひとまず物事を書き留められる場所はできたので、この話を改めて取り上げてみようと思います。

まず、先に一点断っておきます。私は SSR がよくないと言っているわけではありません。ただ、私が見てきた傾向からすると、あまりにも多くの人がこれを濫用しており、全体として流れに身を任せながら、ある問題を避けようとしているように思います。このことについては多くの人と話し合ってきましたが、返ってくる反応の大半は曖昧でした。自分の構想も話したことがありますが、相手はいつも、そこにはさまざまな問題や境界条件があるのではないかと懸念します。それに正直なところ、毎回この考え方の全体を最初から最後まで説明し直したいとも思いません。そこで今日は、いっそのこと詳しく書き残し、いくつかの疑問と技術的な原理を明確にしてみようと思います。

SSR の乱用

私がウェブフロントエンド開発を学び始めたばかりの頃、最初に触れたフルスタックフレームワークは、おそらく Next.js でした。これは非常に優れたフレームワークだと思います。確かに性能上の問題はいくつかありますが、それは確かな開発者体験と引き換えに生じているものであり、その点を責めることはできません。しかし、フロントエンドの概念や知識への理解が深まるにつれ、私にはだんだん合わなくなってきたのではないかと感じました。というのも、私は Rust を書くのが大好きで、低レイヤーを調べることも好きですし、組み込みシステムで遊んだ経験もあるため、性能に対して独特のこだわりがあるからです。こだわるといっても、何もかも限界まで追い込むという意味ではありません。明らかに性能がよく、しかもわずかな労力で実現できる方法がすでにあるのに、なぜ誰もそうしないのか、ということです。だから Next を使うたびに考えてしまいます。たとえ非常に単純なページで、ほとんどが固定され、変える必要があるのは一か所だけでも、なぜページ全体をもう一度レンダリングしなければならないのでしょうか。なぜでしょうか。私の理解では、バックエンドが Rust で書かれていなくても、限界性能を追求しなくても、JavaScript インタープリター上で動いていたとしても、実際に変わるわずかなデータにかかるコストは比較的小さいはずです。そして「レンダリング」は、ページ全体を再レンダリングするのではなく、ごく低いコストで実現できるはずです。

やがてそれは私の心の引っかかりとなり、考えずにはいられない問題になりました。しかし皮肉なことに、この10年近くにわたるフロントエンドの発展と、VueSvelteSolid、そしてそれらのフレームワークである Next.jsTanStack StartRemixNuxtSveltekitSoild Start を見渡したあと、どうやら皆が道を踏み外してしまったように思えました。とりわけ ReactRSC は誤りだと思いますが、それが存在するのにも実は理由があります。この点については、また機会があれば詳しく話してみたいと思います...

私から見ると、RSCをはじめとする一連のSSRフレームワークは、実のところrenderを濫用しています。SSGは確かに私が望むスタイルを実現しています。つまり、ページはビルド時に生成済みで、リクエスト時に再レンダリングする必要がありません。しかし、真の動的機能は備えていません (x)。一方、ISR は SSG と SSR の間で均衡を取り、ある程度の柔軟性を実現しているように見えますが、本質的には私の期待に応えていません。ISR が登場したのは、実際には SSR の性能の低さを補うためだと思います。その後のさまざまな CDN キャッシュ戦略なども含め、これらはすべて一種の回避策にすぎず、真に洗練された解決策ではありません。

もちろん、SSR 自体が間違いなのではありません。SSR は多くの現実的なユースケースに対応できる優れた仕組みです。問題は、こうしたフレームワークが SSR を極端なまでに既定の選択肢として位置づけた結果、開発者が実際に SSR を使う場面のうち、おそらく 95% は本来まったく必要としていないことにあります。ページ上のコンテンツの大部分は固定されており、本当に動的な部分はごくわずかなのに、リクエストのたびにコンポーネントツリー全体を実行しています。

とはいえ、近年の React には多少の改善が見られ、React 19.2 には PPR があります。しかし、これも実行時には高コストな renderToString() が残っており、真の解決策というよりは回避策にすぎません。そこで、ほとんどのものがビルド時にすでに確定しているのなら、レンダリングを直接コンパイル時に行えばよいのではないか、と考えました。

コンパイル時レンダリング?

レンダリングをコンパイル時に行うというのは、かなり馬鹿げた話に聞こえます。というのも、サーバーサイドレンダリングが存在する根本的な理由は、実行時のその時点にならなければ解決できない値や条件があり、それが何なのか分からない以上、判断のしようもないからです。だから、たいていの人が真っ先に私の考えを退けるのも無理はありませんし、理解できます。

とはいえ、実はこれを実現するための、なかなか巧妙な Pipeline を思いつきました。だからこそ、これを一種の Protocol と呼ぶ必要があります。それとは別に、私は以前から、フルスタックフレームワークがフロントエンドとバックエンドの境界を曖昧にするのは非常に愚かなことだと思っています(もっとも、ここ数年の Next.js がこの点で優れた開発者体験を提供してきたことは認めざるを得ず、そのため多くの初心者がフルスタックアプリケーションの開発はとても簡単だと感じているのですが、実際にはそこから多くのセキュリティ上のリスクも生じています——話がそれました)。

コンポーネント内でのデータ取得も同じで、私は境界を明確にする方法を好みます——コンポーネントは純粋なコンポーネントに徹し、データ取得は完全に切り離すべきです。そして、この前提を受け入れて純粋なコンポーネントとデータを分離すると、話は面白くなります。データそのものが実は分類できることに気づくのです。

ここで着想を与えてくれた TypeScript にも感謝したいところです。いわゆる「値」がコンパイル時に実際には何であるかは重要ではなく、大切なのはその型です。slot にどのような内容を入れる場合でも、それが Open String のようなもの(無限に多くの値を取り得る文字列)でない限り、有限個の可能性を表す型として包むことができます。たとえばダッシュボードを作っていて、ある部分を条件付きで描画する必要があるとしても、結局はユーザーや管理者など、すべて定義し切れる型にすぎません。現実世界で条件付き描画や論理判断が必要になる箇所のほぼすべては、いくつかの明確な可能性へと整理できます。

そして、これらの可能性をうまく記述する方法こそが JTD です!

JTD 仕様

JTD(JSON 型定義)RFC 8927 で定義されており、空、参照、型(ブール値、文字列、タイムスタンプ、および各種精度の数値型)、列挙、要素、プロパティ、値、判別子という8つのスキーマ形式があります。さらに、どのスキーマにも nullable を指定できます。JTD の利点は言語をまたいで利用できることで、JavaScriptRustGo をはじめ、ほぼすべての言語に対応する型マッピングがあります。そのため、JTD は自然とフロントエンドとバックエンドをつなぐ橋渡しとなり、しかも媒体である JSON はもともとフロントエンドとバックエンドの最小公分母です。現実のウェブサイトにあるアプリケーションの 95% は、文字列や数値フィールドを表示するか、ブール値で判定する程度にすぎず、そのすべてがこの範囲に収まります。この点が明確になれば、実際に扱える余地はかなり広がります。もちろん JTD も完璧ではなく、Markdown のような例外もありますが、それについては後で詳しく説明します。実のところ、ここにこそ私が SSR の意義を本当に認める理由があります。

センチネル値

まずは簡単な話から始めましょう。すべての動的な値の型がすでに分かっているなら、コンパイル時に renderToString()React コンポーネントを一度実行できます。ただし、実際のデータではなく、Sentinel で模擬したデータを使います。これは具体的にどういう意味でしょうか?

{ user: { name: "Alice", age: 30 } }

データがこのような形だとすると、次のように置き換えられます

{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }

これらの %%SEAM:...%%センチネルであり、各動的値の位置を確保します。React がこのセンチネルデータを使って renderToString() を実行し終えると、出力された HTML 内でそれらの位置がマークされます。続いて、ビルドパイプラインがこれらのセンチネルを HTML コメント形式のスロットマーカーに変換します。

<!-- Sentinel -->
<span>%%SEAM:user.name%%</span>

<!-- Slot -->
<span><!--seam:user.name--></span>

ここまで来れば、これらの slot が**「型マーカー付きスロット」だと、もうお気づきかもしれません。この時点でサーバー側の実行環境が行うことは極めて単純です。実データを取得してこれらの穴に値を埋める、つまり純粋な文字列置換だけです。renderToString() も JavaScript の実行環境も vDOM もまったく必要ありません。HTML コメントを解析して文字列置換**を行える言語なら、どれでもバックエンドとして使えます。Rust、Go、TypeScript のどれでも構いません。これがフレームワークではなくプロトコルである理由です。

ここで、条件付きレンダリングはどうするのかと思うかもしれません。たとえば、あるフィールドが null の場合、ひとまとまりのコンテンツ全体を表示しないようにする必要があります。しかし実際には、これも実行時の JavaScript で判定する必要はなく、代わりにプロトコルでこのケースを定義できます。

<!--seam:if:user.avatar-->
<img src="<!--seam:user.avatar-->" />
<!--seam:endif:user.avatar-->

条件付きレンダリングも、リストレンダリングも同様です。

<!--seam:each:messages-->
<li><!--seam:$.text--></li>
<!--seam:endeach-->

パターンマッチングさえも

<!--seam:match:status-->
<!--seam:when:active--><span class="green">Active</span>
<!--seam:when:disabled--><span class="red">Disabled</span>
<!--seam:endmatch-->

これがコメントの妙です。ここでコメントを選んだのは、たまたま正当な HTML だからというだけで、それ以上の理由はありません。では、こうした条件ブロックやループブロックは、どのようにビルド時に特定して生成するのでしょうか。実は、なかなか巧妙な方法があります。仮想 DOM がなくても、HTML を 2 回レンダリングして差分を取ればよいのです。条件付きレンダリングを例にすると、まず完全な番兵データでレンダリングし、次にある nullable フィールドを null に設定してもう一度レンダリングします。2 つの出力を比較して消えた HTML が、そのフィールドによって制御される条件ブロックなので、あとは <!--seam:if:...--> で直接囲むだけです。配列も同様です。

CTRかも?

すると、また誰かが心配するはずです。条件付きレンダリングの組み合わせは指数関数的に爆発しないのでしょうか。たとえば、3~5個の変数で条件付きレンダリングを制御し、それぞれに10通りの可能性があれば、掛け合わせた数は確かにかなり大きく見えます。しかし実際には、見た目ほど恐ろしい数字ではありません。まず、多くの文字列型の値は内容そのものを列挙する必要がなく、値があるかないか nullable だけを見ればよいので、実質的には2通りです。次に、本当に条件判定に使うフィールドの型は必ず列挙可能です。たとえば、open string を使って if 判定をすることはないでしょう。さらに、仮に全組み合わせを本当に列挙しても、現代の CPU なら数 ms で処理できるかもしれません。しかも負荷はすべてコンパイル時に発生します。Rust のコンパイルと同じで、コンパイル時には比較的大きなコストを払うものの、実行時には単純な文字列置換だけになります。どう計算しても割に合う取引ですし、ましてや特別な魔法は何もありません。ただし、ここで一つ小さな伏線を張っておきます。後ほど複雑な導出を行うために、ごく小さな組み込み JavaScript 実行環境を導入します。これは一種の妥協ですが、その理由と、理論上は不要にできることについては後で説明します。

これが私の言う CTR(コンパイル時レンダリング) です。制約は SSR とまったく同じです。たとえば、<!--seam:path--> でテキストを挿入すると HTML エスケープ(&<> など)が自動的に行われ、生の HTML を挿入するには <!--seam:path:html--> を明示的に使う必要があります。存在しないデータパスは、テキスト slot では空文字列になり、属性 slot では挿入自体が省略されます。また、each ブロックに配列以外が渡された場合、そのブロックはそのままスキップされます。これらの挙動は SSR フレームワークで遭遇する境界事例と本質的に変わりません。SSR のレンダリング処理をコンパイル時に移しただけです。CTRビルド時に、すべての条件変数について型が取りうる値の直積を走査し、型を満たす任意の自動生成モック値、またはユーザーが上書き指定した特殊なモック値を使います(通常、すべての HTML バリエーションの生成に必要なモック値を手動で用意する必要はありませんが、指定することもできます。指定しなければ、各型に適合する値が自動的に選ばれます)。各組み合わせを一度ずつレンダリングし、結果の差分からすべての条件ブロックとループブロックの境界を割り出して、最終的に完全に展開された HTML の骨格を得ます。数学的には、実行時に渡されるデータがこれらの型定義に適合している限り、必ずこの骨格へ正しく挿入できます。考えられるすべての分岐経路が、コンパイル時にすでに網羅されているからです。

一貫性は?

では、整合性はどう保証するのでしょうか。答えは JTD という契約です。フロントエンドとバックエンドは分離されていても、双方が同じ JTD スキーマに従う限りデータ型は一致し、不整合は生じません。

しかし、ほかのフレームワークにはない課題が一つありました。バックエンドを JavaScript ランタイムから解放した以上、バックエンドはフロントエンドと一体である必要がなく、RustGo で自由に書けます。TypeScript のフルスタックフレームワークなら、フロントエンドとバックエンドの整合性は型で直接保証できますが、ほかの言語ではどうでしょうか。実際、ここではコード生成を使っています。バックエンドがどの言語で書かれていても、それを基準とし、フロントエンドで利用できる変数と型を直接 TypeScript として生成し、フロントエンドからインポートできるようにします。これにより両者の境界は明確になりますが、実際には TypeScript のフルスタックフレームワークと同様に同じフォルダー内に置けるため、モノレポの慣習に沿い、アプリケーションプログラミングインターフェースや gen OpenAPI のようなものを手作業で書かなくても、互いを直接呼び出せます。ここではプライベートパス /_seam/ をフレームワークのデフォルトエンドポイントとして使用しており(Nuxt と同様に設定可能です)、JTD Typed-RPC を実行するだけでデータ転送とオリジン間リソース共有の問題を解決できます。

CTR × SSR

次は raw HTML slot ですが、これはまさに、あの 5%エッジケースに話が戻ります。たとえば MarkdownRich Text のようなものは、コンパイル時にその値を知ることがそもそも不可能であり、しかも型で制約するには非常に大きなコストがかかります。もちろん、プロトコルを拡張して Markdown の構文をすべて列挙し、実行時に解析することもできますが、その作業量は Markdown レンダラーを一から書き直すのとほぼ同じではないでしょうか。一方、こちらが制約するのは必要最小限の数種類の型だけであり、レンダリングエンジン全体を書き直す作業とはまったく別物です。これも最初に述べた思想、つまりより小さなコストで、より洗練された方法によって問題を解決するという考えに立ち返ります。

つまり朗報なのは、raw HTML slot によって CTRSSR を実際に共存させられることです。これは、ページのユーザーインターフェースの大部分を CTR で構築し、実行時コストをほぼゼロにしつつ、中心となる Markdown 記事だけを SSR で描画できるということです。バックエンドの制約から解放されたので、TypeScript から従来の SSR の描画方法を読み込んでもよいですし、もっと発想を広げてもよいでしょう!バックエンドが Rust なら、もちろん Rust の Markdown コンパイラーで描画できます。最終的に HTML 文字列を生成し、それを raw slot に挿入できれば表示されます。CTR を使うからといって SSR が使えないわけではなく、両者はまったく問題なく共存できます。

PPR の落差

このように考えると、CTR は本質的に PPR(部分事前レンダリング) の最も理想的な形です。キャッシュできるものはすべてコンパイル時にレンダリングを終えるため、実行時のオーバーヘッドはゼロで、コストがかかるのは実際に変化するわずかなデータだけです。では、React 19.2 の PPR とは何が違うのでしょうか。答えは、私の方式のほうがより徹底的で、より大胆だということです。PPR 理想的な状況で静的な部分がすべてキャッシュされ、更新が必要なのがごく一部の動的コンテンツだけであっても、その変更が単純な文字列の変更にすぎなくても、React は renderToReadableStream() をもう一度実行しなければならず、そのコストは決して小さくありません。

一方、CTR はこの境界を明確にしています。文字列、数値、真偽値などの単純な型は文字列を直接置換し、Markdown のように全パターンの列挙コストが極めて高い複雑な型だけが、実際のレンダリング処理を通ります。しかも、ここでいうレンダリングは従来の意味での SSR ではなく、どのプログラミング言語によるレンダリング方式でも構いません

あと RSC

では次に、RSC(React サーバーコンポーネント)について話しましょう。以前、RSC は誤りかもしれないと述べました。それはフロントエンドとバックエンドの境界をあまりにも曖昧にしたからで、~~少なからぬセキュリティ上のリスクをもたらし~~、数多くの CVE まで生んだからです。しかし実際には、これは Next.js やほかの SSR 方式に負うところのほうが大きく、ユーザーには使わないという選択肢がそもそもありません。動的なものが欲しければ SSR を使うしかない状態になっていますが、実のところ、私たちのような文字列置換でもまったく問題なく実現できます。その一方で、RSC がサーバー側に非常に重要な能力、つまり任意の React コンポーネントをレンダリングする能力、言い換えれば任意のコードを実行する能力を与えたことは認めざるを得ません。

とはいえ、任意のコードを実行する以上、そのすべてを型で網羅するのはほぼ不可能です。仮に可能だとしても、その作業量は React コンパイラーを書くより少ないとは限りません。したがって、RSC のこの能力には存在する理由があります。CTRRSC は共存できるのでしょうか? 答えは「できる」であり、両者はまったく競合しません。ただし、これはフレームワーク層で解決すべき問題であって、プロトコル自体の範囲ではありません。私はまだ実装していませんが、TanStack Start の方式を参考にできそうです。追加で htmljs のパッケージを一つずつ送るだけなので、理論上はかなり簡単に実装でき、少なくとも必要な作業量は明確に見積もれます。

raw HTML slot の話に戻ると、これが主に解決するのは MarkdownRich Text といった場面です。レンダリングされた HTMLdangerouslySetInnerHTML 経由で注入し、ハイドレーション後はその領域はインタラクションに一切関与しない、つまり "死んでいる" わけです。この部分はできるだけ最小限にすべきで、記事に枠線やスタイルを付けたいなら、それは React コンポーネントとして書くべきで、この HTML の中に混ぜ込むものではありません。この方式の欠点はハイドレーション後に変更できないことですが、その前提のもとでの "SSR" のコストを極めて低く、ほぼゼロコストにまで抑えられるのも事実です。本当に SSR が必要な場面のうち、およそ 60% はこの種の静的 HTML 注入で、残りの 40% になって初めて、RSC のようにサーバー側で任意のコンポーネントを実行する能力が必要になります。

UI 非依存性

最後に、非常に魅力的なのがプロトコル非依存性です。本質的には、renderToString という一つの要点さえ押さえればよく、その手前でどの UI フレームワークを使うかはプロトコルとは関係ありません。では、これは Astro と何が違うのでしょうか?でも安心してください。私が作っているのは、決してもう一つの Astro ではありません。表面的には私の構想が Astro のアイランドに少し似て見えますが、実際には大きく異なります。

私は単一のページで複数のランタイムをハイドレーションしているわけではありません。うーん、実際のところ、それが本当に必要になる場面は非常に限られていると思います。異なる技術スタック間でコンポーネントの状態をやり取りするコストは非常に高くなります。これはむしろ、ある技術スタックから別の技術スタックへ移行する際、一度にすべてを置き換えられないために使う過渡的な手段です。さらに、Astro は本質的に MPA ですが、私たちの仕組みではハイドレーション前は MPA、ハイドレーション後は SPA にできます。Next.js のようにクライアント側ルーティングを備え、ページをまたぐアニメーションも実現できるのです。これは Astro にとって喉から手が出るほど欲しい機能でしょう。

AstroとSSG

また、Astro の設計が本当に役立つのは、もともと複数の技術スタックをまたぐ場面です。速さを求めて使うのにフレームワークは一つだけ、たとえば React だけを導入して Vue は導入しないのであれば、Astro がうたう 「高速」 は偽りの命題だと私は思います。初期画面がすべて HTML で表示されるのは確かですが、何らかの操作性を持たせようとすればハイドレーションが必要になり、そのコストとして React ランタイム 全体をダウンロードすることになります。これは私たちのハイドレーションと本質的な違いはありません。もちろん、将来的にはアイランドの考え方を採用し、シェルルーターを追加して、UI フレームワークをまたぐ SPA ナビゲーションを実現することもできますが、それは今後のロードマップの話で、少なくとも今の私は急いでいません。

最後に、従来の SSG との比較ですが、私は SSG と同じことを実現しました。本質的には、コンパイル時に確定できるものをすべてレンダリングしておくということです。しかし、私たちのほうがより動的です。単純型の slot の値は実行時に完全に置き換えられるからです。つまり、SSG をある種の MPA のエントリーポイントとしてレンダリングした、と考えることができます。ハイドレーション前は MPA、後は SPA になり、同時に「動的」な機能も維持します。

ハイドレーションエラー?

最後は、私が大嫌いなハイドレーションの不一致です。きっとあなたも好きではないでしょう。しかし突き詰めれば、これは単に DOM の状態が一致していないということです。Next.jsApp Router のような従来のフレームワークは、アプリケーション全体を React で包もうとするため、ユーザー側でブラウザーが何らかのマークアップを挿入すると、ハイドレーションエラーが発生する可能性があります。私たちが抱える制約は従来のサーバーサイドレンダリングと同じですが、TS を使う際には __root というハイドレーション用の div でも包み、ハイドレーション領域がメタデータ領域まで及ばないようにすることで、より堅牢なハイドレーションを実現しています。また、React 19 は <title><meta><link> などの文書メタデータタグを標準でサポートしています。ページ全体の <html> ではなく、その中の特定の <div> だけをハイドレーションする場合でも、コンポーネント内で <title>My Page</title> を直接レンダリングすれば、React が自動的にそれを <head> 内へ引き上げます。

ハイドレーションの不一致に話を戻すと、コンパイル時には各 slot の型がすでに分かっているため、CTR の等価性チェックをもう一段追加できます。型定義から導出した模擬データを完全に展開された HTML に流し込み、従来の renderToReadableStream() を一度実行してから、2 つの DOM ツリーが意味的に等価かどうかを比較し、書式上の差異は無視します。書式にはわずかな違いが生じる可能性がありますが、DOM 構造が厳密かつ完全に等価でさえあれば、実際に ハイドレーションの不一致 と完全に決別できます。従来の SSR ではなぜできないのでしょうか。それらがこの処理を実行時になって初めて行うのに対し、CTR の構造では、これらの制約をすべてコンパイル時に確定させる必要があるからです。もちろん、any という逃げ道も用意していますが、少なくとも TypeScript と同様に、any を使うなら、その結果は自ら引き受けなければなりません。CLI はコンパイル時に、開放文字列による逃げ道である any が不一致を引き起こす可能性を警告します。

サーバーレス

もちろん、サーバーレスという選択肢も外せません。ここ数年でサーバーレスの使い勝手は非常によくなり、私の評価は「費用がかかること以外、ほとんど欠点がない」です。一方、CTR はもともとこの用途に非常に適しています。実行時に行う処理が軽量かつ小規模なので、サーバーレス環境では非常に高速に動作し、応答時間とオーバーヘッドの両方が大幅に改善されます。改善できないのは Markdown のレンダリングのようなケースくらいですが、それは本来、ビジネスロジック側で最適化すべきです。たとえば Markdown を事前にレンダリングして保存しておけば、リクエストのたびに再レンダリングする必要はありません。今のこのサイトもそうしています。これはビジネス層の問題であり、フレームワークが解決できるものではありませんが、それ以外の単純なロジックのオーバーヘッドなら、フレームワークによってほぼゼロまで抑えられます。従来の SSR と比べると、もはやオーバーヘッドの桁が違います。どれほど小さいかというと、だいたい数百マイクロ秒から 1 ミリ秒です。従来の SSR では考えられない水準です。

では、バックエンドが別の言語だったらどうすればよいのでしょうか。Cloudflare Workers を例にすると、実は多くのサーバーレス環境が WASM に対応しているため、ほかの言語も WASM バイナリにコンパイルすれば、同じようにバックエンドとして利用できます。本質的には、クライアントサイドレンダリングのようにフロントエンドを完全な静的リソースへコンパイルしているだけですが、この一式を専用のブリッジ /_seam/ と組み合わせることで、本物のフルスタックフレームワークと同等の動的機能を実現でき、当然ながらサーバーレス環境にも完全に対応できます。

Seam と SeamJS

というわけで、SeamSeamJS の関係がこんがらがっている人もいるかもしれません。でも実はとても単純です。Seam はプロトコルで、Sentinel で動的な位置をどう印付けるか、それをどう slot のマーカーに変換するか、条件ブロックとループブロックの diff 検出をどう行うか、そしてランタイムが AST をもとにどうデータを注入するかを定めています。プロトコル自体は言語に依存せず、HTML コメントを解析でき、文字列置換ができるバックエンドなら何でも実装できます。SeamJS はフレームワークで、このプロトコルに基づいた具体的な実装のひとつです。ViteTanStack RouterTanStack Query といった既存の車輪を、その上で手の届かない部分、たとえば私の作った skeleton の抽出や注入エンジン、CLI などを補っています。

とはいえ正直なところ、SeamJS はまだ非常に基礎的な段階です。動かすこと自体には特に問題ありませんが、実際のプロジェクトで使うには、まだ長い時間をかけて磨き上げる必要があります。データ転送経路の抽象化など、アーキテクチャ上の刷新についてもさらに検討していますが、今後のバージョンでもこの方向性を維持するかどうかはまだ分かりません。フレームワークとしては、TypeScript のフルスタックフレームワークと、Rust をバックエンドに採用した主力フレームワークを必ず作るつもりです。一方、Go 関連の実装は今後のバージョンで削除する予定です。自分の手には負えず、保守にまで手が回らないと感じています。

Seam canmi21/seam

Rendering is a protocol, not a render-time computation.

TypeScript 39 stars 1 forks MIT 1 open issues Jul 3, 2026

Web フレームワークだけではない

では、これを作ると何の役に立つのでしょうか。実は、その用途はウェブだけに限られません。たとえば、Transport チャネルも抽象化すれば、あとから ElectronTauri のようなデスクトップ環境へ移植できます。HTTP の転送パイプラインを IPC 通信に置き換えるだけで、同じ Seam プロトコルがそのまま動きます。その結果、Electron アプリは起動時に読み込み画面を表示する必要がなくなり、多くの要素をサーバーサイドレンダリングのように即座にローカルで描画できます。これこそ CTR の魔法です!

JS ランタイムを持たない代償

では、なぜ SeamJS は最終的に JS ランタイムを導入したのでしょうか。この方式を実装する際に、ある問題にぶつかったからです。CTR の理想では、初回表示用データに非常に厳しい制約が課されます。データは完全に導出可能な確定的な構造体でなければならず、計算ロジックすら一切含められず、許されるのは条件だけです。これはあまりにも厳しい制約です。フレームワークの開発体験を考えれば、従来の React に慣れた開発者は、当然コンポーネント内で何らかの計算を行い、得られた値をそのまま使えることを期待します。CTR の制約を厳密に守って ready-to-display の状態に到達しようとすると、作業は非常に煩雑になり、同じコンポーネントを二度書くことさえあり得ます。

しかし、実は解決策があります。Web の性質上、フロントエンドでは JavaScript しか実行できず、当然コンポーネントも JS でしか動かせません。そのため、この問題を解決するにはバックエンドにも JS の実行能力が必要です。TypeScript のフルスタックプロジェクトなら既存のランタイムをそのまま利用でき、Rust なら QuickJS のようなごく小さな JS ランタイムを組み込むだけで済みます。ここでいう JavaScript ランタイムは標準 JS の一部だけを実装したもので、完全な OS API を備える Bun や Node のようなランタイムとはまったく異なります。本当にデータの導出だけに使われます。

これにより、コンポーネント内には引き続き計算ロジックを記述できます。バックエンドはデータを取得した後、短い JavaScript を実行してデータから ready-to-display の状態を導出し、それをフロントエンドへ返して初期画面をハイドレーションします。CTR は厳密に構造化された派生データを得られるので満足し、利用者側の開発体験も、コンポーネントを一度書くだけで済むため向上します。もちろん、純粋な型制約を厳密に守るなら、バックエンドを JavaScript 実行環境なし にすることも実際に可能で、この約束は厳密には今も成立しています。とはいえ、追加される JavaScript 実行環境は非常に小さく、その処理負荷、メモリ使用量、容量はいずれも Node などよりはるかに少なく、わずか 200~300 KB 程度でありながら、非常に大きな柔軟性をもたらします。

今後の展望

ここまで散々語ってきたけれど、実際いつ のか。たぶん、まだまだ先です。本当に わけじゃなくて、今の段階ではこいつに人質に取られている部分があまりに多すぎるんです。開発にしか使えないとなると、実質アプリが書けません。何を書いてもまずフレームワークに足を引っかけられます。ましてや最近はこのサイトまわりばかり書いていたわけです。だから吹っ切れました。まずサイトをある程度の規模まで作ってしまえばいいのです。そうすれば自分の要求が何なのかが分かります。その後あらためて SeamJS を書くときには TODO List が手元にあります。一つずつ機能を実装していけば使えるようになります。ついでに移行の話と Benchmark でもう一本水増しできる?よし、。理念はここにあります。今日使えるかどうかはひとまず措いて、少なくとも考え方の上ではプロトタイプは動きました。おやすみ 💤