Skip to content

SSRアイランド ​

アイランドとは、サーバーがReactページ内でHTMLにレンダリングし、ブラウザがそれぞれのフレームワークでハイドレートするマイクロアプリです。@tuvix.js/reactのTuvixIslandを使うと、1つのReactホストにVue、Svelte、Angular、Reactのアイランドを載せられます。ユーザーはJavaScriptが実行される前にスタイル適用済みのコンテンツを目にし、各アイランドはそのフレームワークがロードされた時点でインタラクティブになります。

必要なもの ​

要素パッケージ実行場所
サーバーレンダリングを行うReactホストページあなたのアプリ(react-dom/server、TanStack Startなど)サーバー + クライアント
TuvixIsland、TuvixReactApp@tuvix.js/reactサーバー + クライアント
アイランドモジュール:createSsrVueMicroApp、createSsrSvelteMicroApp、createSsrAngularMicroApp、createSsrReactMicroAppアダプタークライアント
renderVueToString、renderSvelteToString、renderAngularToString@tuvix.js/<adapter>/serverサーバーのみ
getAssets、renderAssetTags@tuvix.js/viteサーバーのみ

オーケストレーターは関与しません。TuvixIslandがアイランドモジュールのmount、update、unmountを自ら呼び出します。

リクエストの流れ ​

text
server                                         browser
──────                                         ───────
renderVueToString(Card, props) ─┐
                                ▼
<TuvixIsland ssrHtml={html}> renders           HTML arrives, CSS links in <head>
<div data-tuvix-app="card">…html…</div>   ──►  apply → styled first paint
getAssets([...]) → <link rel=stylesheet>
                                               hydrateRoot(host)
                                               TuvixIsland reads its innerHTML from the DOM
                                               React leaves the island's children alone
                                               useEffect → card.mount({ container, props })
                                               Vue adapter: createSSRApp → hydrates in place
                                               tuvix:mounted { hydrated: true }

1. Viteを設定する ​

ts
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import vue from '@vitejs/plugin-vue';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { tuvix } from '@tuvix.js/vite';

export default defineConfig({
  plugins: [react(), vue(), svelte(), tuvix()],
});

TypeScriptがvirtual:tuvix/assetsを認識できるように、compilerOptions.typesに"@tuvix.js/vite/client"を追加します。

2. アイランドモジュールを作成する ​

各アイランドは、アダプターのSsr版で作成したモジュールをエクスポートします。これらのファイルはブラウザで実行されるため、サーバー用レンダラーをimportしてはいけません。

ts
// src/islands/vue-card/index.ts
import { createSsrVueMicroApp } from '@tuvix.js/vue';
import VueCard from './VueCard.vue';

export const vueCard = createSsrVueMicroApp({ name: 'vue-card', App: VueCard });
ts
// src/islands/svelte-counter/index.ts
import { SvelteMap } from 'svelte/reactivity';
import { createSsrSvelteMicroApp } from '@tuvix.js/svelte';
import Counter from './Counter.svelte';

// SvelteMap makes TuvixIsland prop changes update in place instead of remounting.
export const svelteCounter = createSsrSvelteMicroApp({ name: 'svelte-counter', App: Counter, SvelteMap });
ts
// src/islands/angular-contact/index.ts
import { createSsrAngularMicroApp } from '@tuvix.js/angular';
import { ContactComponent } from './contact.component';

export const angularContact = createSsrAngularMicroApp({ name: 'angular-contact', component: ContactComponent });

3. サーバーでアイランドのHTMLをレンダリングする ​

サーバー用レンダラーは、サーバーコードだけがimportするモジュールに置きます。

ts
// src/server/render-islands.ts (server only)
import { renderVueToString } from '@tuvix.js/vue/server';
import { renderSvelteToString } from '@tuvix.js/svelte/server';
import { renderAngularToString } from '@tuvix.js/angular/server';
import { render } from 'svelte/server';
import VueCard from '../islands/vue-card/VueCard.vue';
import Counter from '../islands/svelte-counter/Counter.svelte';
import { ContactComponent } from '../islands/angular-contact/contact.component';

export async function renderIslands(): Promise<Record<string, string>> {
  const [card, counter, contact] = await Promise.all([
    renderVueToString(VueCard, { title: 'Hello' }),
    renderSvelteToString(Counter, { start: 3 }, { render }),
    renderAngularToString(ContactComponent, { appId: 'contact', props: { heading: 'Write to us' } }),
  ]);
  return { 'vue-card': card, 'svelte-counter': counter, 'angular-contact': contact };
}

render*ToString関数はサーバー専用です。ブラウザでは''を返します。ブラウザで決して実行されないコードから呼び出してください:サーバーエントリー、APIルート、あるいはTanStack StartのcreateServerFnのようなフレームワークのサーバー関数です。アイソモーフィックなルートローダーからは呼び出さないでください。そのローダーはクライアント側のナビゲーション時にブラウザでも実行されて''を受け取り、さらにローダーがimportしているため、バンドラーがVue、Svelte、Angularのサーバー用レンダラーをクライアントに含めてしまいます。

4. アイランドをページに配置する ​

tsx
// src/pages/Home.tsx
import { createContext, useContext } from 'react';
import { TuvixIsland } from '@tuvix.js/react';
import { vueCard } from '../islands/vue-card';
import { svelteCounter } from '../islands/svelte-counter';

// Filled by the server entry; empty on the client.
export const IslandHtml = createContext<Record<string, string>>({});

export function Home() {
  const html = useContext(IslandHtml);
  return (
    <main>
      <TuvixIsland
        name="vue-card"
        app={vueCard}
        props={{ title: 'Hello' }}
        ssrHtml={html['vue-card']}
        fallback={<p>The card failed to load.</p>}
      />
      <TuvixIsland name="svelte-counter" app={svelteCounter} props={{ start: 3 }} ssrHtml={html['svelte-counter']} />
    </main>
  );
}

サーバーとクライアントには同じpropsを渡してください。サーバーが{ start: 3 }でレンダリングし、クライアントが{ start: 4 }でマウントすると、フレームワークはハイドレーションの不一致を検出します。

5. アイランドのCSSをheadでリンクする ​

ViteはCSS Modules、Vueの<style>ブロック、SvelteのスタイルをJavaScriptから注入します。何も対策をしないと、サーバーのHTMLがそのJavaScriptより先にブラウザに届き、最初の描画はスタイルなし(FOUC)になります。getAssetsはエントリーが必要とするスタイルシートのURLを返すので、サーバーはそれらを<head>でリンクできます。

tsx
// src/entry-server.tsx
import { renderToString } from 'react-dom/server';
import { getAssets, renderAssetTags } from 'virtual:tuvix/assets';
import { Home, IslandHtml } from './pages/Home';
import { renderIslands } from './server/render-islands';

export async function render(): Promise<string> {
  const [islandHtml, assets] = await Promise.all([
    renderIslands(),
    getAssets(['src/entry-client.tsx', 'src/pages/Home.tsx']),
  ]);

  const body = renderToString(
    <IslandHtml.Provider value={islandHtml}>
      <Home />
    </IslandHtml.Provider>,
  );

  return `<!doctype html>
<html lang="en">
  <head>
    <title>Home</title>
    ${renderAssetTags(assets)}
  </head>
  <body>
    <div id="app">${body}</div>
    <script type="module" src="/src/entry-client.tsx"></script>
  </body>
</html>`;
}
  • モジュールのパスはViteのルートからの相対パスで渡します。本番ビルドでは、各パスはビルドの入力か、クライアントビルド内のimport()の対象でなければなりません。それ以外はrejectされます。
  • Rollupが2つのimport()対象を1つのチャンクにまとめると、マニフェストはそのチャンクをモジュールパスではなくハッシュ付きの名前で記録するため、getAssetsはそのパスに対してrejectします。サンプルでは@angular/coreがページのチャンクに入るとこの問題が起き、@angular/coreに専用のチャンクを割り当てるmanualChunksで解決しています。
  • getAssetsは静的importをたどり、import()の対象はスキップします。遅延importされ、かつサーバーでレンダリングされるページやアイランドは、リストに独自のエントリーが必要です。
  • devでは?direct付きのCSS URLを返し、Viteが独自の<style>を注入すると対応する<link>を無効化するため、HMRによる編集が反映されます。
  • Angularアイランドはスタイルをインラインで持ちます:renderAngularToStringはコンポーネントの<style>タグをマークアップの前に配置します。
  • Svelte:css: 'injected'でコンパイルする(スタイルはアイランドのHTMLにインライン化されます)か、emitCssを維持してgetAssetsでリンクするかのどちらかにします。両方は使いません。

6. クライアントでハイドレートする ​

tsx
// src/entry-client.tsx
import { hydrateRoot } from 'react-dom/client';
import { Home } from './pages/Home';

hydrateRoot(document.getElementById('app')!, <Home />);

クライアントはアイランドのHTMLなしでレンダリングします。各TuvixIslandはハイドレーション中にページから自身のマークアップを読み取り、Reactはそれらのノードを保持し、アイランドモジュールがそれらをハイドレートします。

HTMLが二重に送信されない理由 ​

素朴なアイランドは、サーバーのHTMLを両側でpropとして渡します。するとReactはdangerouslySetInnerHTMLをハイドレートするためにクライアントでもその文字列を必要とし、HTMLはドキュメント内で一度、ハイドレーションデータ内でもう一度送られます。さらに、親が新しい{ __html }オブジェクトで再レンダリングするたびにReactはinnerHTMLを再代入し、アイランドのフレームワークが付与したDOMとリスナーを消し去ります。

TuvixIslandはこの両方を回避します。

  • サーバーでは、ssrHtmlを<div data-tuvix-app={name} data-tuvix-island={id}>に書き込みます。
  • ハイドレーション中は、useIdベースのdata-tuvix-island属性でその要素を見つけ、innerHTMLを一度だけ読み取ります。クライアントはssrHtmlを必要としません。
  • { __html }オブジェクトをstateに保持するため、以降のレンダリングではReactに同じオブジェクトが渡され、Reactがアイランドの子要素を書き換えることはありません。

フレームワークがローダーのデータをどう扱うかは別の問題です。TanStack StartやRemixのローダーからアイランドのHTMLを返すと、フレームワークはそのローダーの結果もページにシリアライズします。ステップ4のように、サーバーエントリーが値を設定するReactコンテキストなど、フレームワークがシリアライズしない手段でHTMLをアイランドに渡してください。

リクエストごとの状態 ​

サーバーコードは1つのプロセスで多数のリクエストを処理します。モジュールスコープで作成したものは、それらすべてにまたがって存続します。

  • TanStack QueryのQueryClientのようなデータキャッシュは、モジュールスコープではなくリクエストごとに作成してください。サーバー上のモジュールレベルのクライアントはリクエストやユーザー間でデータを混在させ、その古いエントリーがハイドレーションの不一致を引き起こします。
  • アイランドが必要とするデータは、そのリクエスト自身のキャッシュからpropsとして渡します。アイランドモジュールはマウント時にクライアント側の状態を自ら作成します。
  • 日付と数値は、timeZoneとロケールを明示してフォーマットしてください。サーバーは通常UTCで動作し、ブラウザはユーザーのタイムゾーンで動作します。timeZoneを指定しないIntl.DateTimeFormatは深夜0時付近で異なる日付をレンダリングし、ハイドレーションを壊します。
tsx
import type { ReactElement } from 'react';
import { renderToString } from 'react-dom/server';

declare function createRequestCache(): { dehydrate(): unknown };
declare function Page(props: { data: unknown }): ReactElement;

export function renderRequest(): string {
  const cache = createRequestCache(); // one per request
  return renderToString(<Page data={cache.dehydrate()} />);
}

headとmetaタグはホストが担当する ​

アイランドはbody内の<div>にレンダリングされます。<title>、<meta>、<link rel=canonical>を出力してはいけません。これらはホスト側(フレームワークのhead API、またはHTMLテンプレート)で設定します。<svelte:head>を使うSvelteコンポーネントでは、renderSvelteToStringの代わりにrenderSvelteを呼び出し、そのhead出力を自分でドキュメントのheadに配置してください。

エラー ​

  • アイランドのmountまたはupdateがrejectされると、TuvixIslandは代わりにfallbackをレンダリングします。アダプターはすでにtuvix:errorをディスパッチしています。
  • サーバー用レンダラーが例外を投げた場合の扱いはサーバーコードが決めます:例外をcatchしてssrHtmlを渡さなければ、アイランドは空の状態で始まり、クライアントでレンダリングされます。
  • TuvixReactAppはコンポーネントを<Suspense>でラップするため、サーバーでのレンダリングエラーはそのアプリだけをfallbackに置き換え、Reactがクライアントで再試行します。

ページのコードからonLifecycle('mounted', ...)とonLifecycle('error', ...)でアイランドを監視できます。ライフサイクルイベントを参照してください。

サンプルプロジェクト ​

リポジトリのexamples/with-ssr-islandsは、これらすべてを組み合わせたものです:React 19ホストを持つExpress + Viteサーバー、Vue 3のカード、Svelte 5のカウンター、zonelessなAngular 20のお問い合わせフォーム(JIT。コンパイラは遅延チャンクとしてロードされます)、TuvixReactAppのニュースレター、そしてフォールバックを示すために意図的に失敗するアイランドが1つ含まれます。src/server/render-islands.tsにサーバー用レンダラーがあり、src/entry-server.tsxはgetAssetsでアセットをリンクし、src/runtime/islands.tsはライフサイクルイベントに反応します。Angularアイランドは、サーバーではpropsオプションからheading入力を受け取り、クライアントでも同じオブジェクトを受け取ります。リポジトリのルートからpnpm --filter with-ssr-islands devを実行するか(ポート5186)、buildしてからstartを実行します(ポート5185。PORTで上書きできます)。サンプルのREADMEでは、各要素とPlaywrightのspecが実行するチェックを説明しています。

アイランドがしないこと ​

  • TuvixIslandはURLからモジュールをロードしません。アイランドモジュールをホストのバンドルにimportしてください。
  • アイランド間で状態を共有しません。props、イベントバス、または独自のストアを使ってください。
  • Angularアイランドは、サーバーのDOMを非破壊的にハイドレートするのではなく、その上に再レンダリングします。Angularを参照してください。
  • ここにあるものはどれも、サーバーレンダリングのステップなしでは動作しません。クライアント専用のアイランドにする場合は、ssrHtmlを省略し、Ssrではないアダプターを使ってください。