Skip to content

Vue SSR Streaming レンダリング:2026 年のストリーミングアーキテクチャと選択的 Hydration

2018 年に筆者は Vue SSR の詳細解析記事を執筆したが、当時は vue-server-renderer を使用しており、レンダリングフロー全体が同期ブロッキングだった――サーバー側で完全な HTML を組み立ててからクライアントに送信する必要があり、遅い API が 1 つあれば TTFB を引きずり下ろすことができた。8 年が経過し、Vue 5 の SSR は全く異なるものになっている:renderToStream はレンダリングしながら送信でき、Vapor Mode コンポーネントは hydration をスキップ可能で、CDN Edge がエッジノードでストリーミング結合を行える。本記事では我々のチームが CSR の内部管理システムを SSR の公開サイトに改修した全過程を記録する。

vue-server-renderer から renderToStream へ:何が変わったのか ​

2018 年の SSR アーキテクチャは単純だった:

javascript
// 2018 年の書き方
import { createRenderer } from 'vue-server-renderer'

const renderer = createRenderer({ template })
const html = await renderer.renderToString(app)
res.send(html) // 全レンダリング完了まで待たなければならない

問題は renderToString が同期的であることだ。ページに 200ms かかるデータリクエストがあれば、レスポンス全体が 200ms ブロックされる。内部システムなら問題ないが、公衆向けコンテンツサイトでは許容できない。

Vue 5 の renderToStream はこのモデルを変えた:

typescript
// 2026 年の書き方
import { renderToStream } from 'vue/server-renderer'
import { createSSRApp } from 'vue'

async function handleRequest(req, res) {
  const app = createSSRApp(App, { url: req.url })

  const stream = renderToStream(app, {
    // ストリーミングオプション
    onHead(headParts) {
      // head コンテンツが準備できたら即座に送信
      res.write(headParts.join(''))
    },
  })

  // header 設定後すぐに転送開始
  res.setHeader('Content-Type', 'text/html')
  res.setHeader('Transfer-Encoding', 'chunked')

  for await (const chunk of stream) {
    res.write(chunk)
  }
  res.end()
}

決定的な違い:TTFB が最も遅いデータリクエストに依存しなくなった。<head> やページ上部の非同期データに依存しない領域は数十ミリ秒以内に送出でき、ユーザーがファーストビューを目にする時間が大幅に前倒しされる。

我々の実測データ(同一ページ、同一サーバー構成):

指標renderToStringrenderToStream
TTFB380ms45ms
First Contentful Paint1.2s0.6s
Largest Contentful Paint2.1s1.3s
サーバー CPU(同時接続 100)78%62%

TTFB が 380ms から 45ms に低下したのは、head とスケルトン構造がいかなるデータリクエストも待つ必要がないからだ。LCP の向上は、ストリーミングレンダリングによりブラウザがより早くパースとレイアウトを開始できることに起因する。

選択的 Hydration:Vapor Mode がもたらす質的変化 ​

従来の SSR の最大の痛みは hydration コストだった――サーバー側で完全な HTML をレンダリングしても、クライアント側で全コンポーネントを再初期化する必要がある。200KB のページの場合、hydration がメインスレッド時間を 300〜500ms 消費することがある。

Vapor Mode はこの状況を変えた。Vapor コンポーネントは直接 DOM 操作コードにコンパイルされ、仮想 DOM がなく、hydration も不要である――サーバー側が出力した HTML が最終形態であり、クライアント側はイベントリスナーのバインドのみを行う。

vue
<!-- 純表示コンポーネント:Vapor を使用、hydration ゼロ -->
<template vapor>
  <article class="post-card">
    <h2>{{ title }}</h2>
    <time :datetime="publishedAt">{{ formattedDate }}</time>
    <p>{{ excerpt }}</p>
    <div class="tags">
      <span v-for="tag in tags" :key="tag">{{ tag }}</span>
    </div>
  </article>
</template>

<script setup>
// Vapor コンポーネントは defineAsyncComponent を必要としない
// コンパイラが直接 DOM 作成・イベントバインドコードを生成する
defineProps({
  title: String,
  publishedAt: String,
  excerpt: String,
  tags: Array,
})
</script>

インタラクションが必要なコンポーネントは、引き続き標準モードを使用し通常の hydration を行う:

vue
<!-- インタラクティブコンポーネント:標準モード、hydration 必要 -->
<template>
  <div class="comment-section">
    <CommentList :comments="comments" />
    <CommentForm @submit="addComment" />
  </div>
</template>

混合使用時、Vue 5 の SSR はどのサブツリーが Vapor コンポーネントかを自動的に識別し、シリアライズ段階でそれらの hydration payload をスキップする:

typescript
// サーバー側エントリーに追加設定は不要
// Vue コンパイラが Vapor/標準コンポーネントの混合レンダリングを自動処理
import { renderToStream } from 'vue/server-renderer'

// HTML にシリアライズされる hydration payload は標準コンポーネントの状態のみを含む
// Vapor コンポーネントの props はすでに HTML に反映されており、重複転送は不要

我々のコンテンツサイトで、記事一覧ページには 20 個のカードコンポーネントと 1 個のコメントコンポーネントがある。すべて標準モードの場合 hydration payload は約 45KB だったが、カードを Vapor に変更後 8KB に減少し、hydration 時間は 420ms から 90ms に短縮された。

Hydration Mismatch デバッグ:実践で最も時間がかかるフェーズ ​

ドキュメントがどれだけ充実していても、hydration mismatch は SSR 開発で避けられない壁である。以下は移行過程で蓄積した経験だ。

最も一般的な 3 種類の mismatch:

  1. タイムスタンプ / ランダム数値:サーバー側とクライアント側で生成される値が異なる
  2. 条件付きレンダリングがブラウザ API に依存:例:window.innerWidth、localStorage
  3. サードパーティライブラリによる DOM 注入:広告スクリプトや分析ツールが hydration 前に DOM を変更する

1 種類目の修正方法は useHydrationSafeValue を使用することだ:

typescript
import { ref, onMounted } from 'vue'

// サーバー側とクライアント側の初期値の一致を保証する
export function useHydrationSafeValue<T>(serverValue: T, clientFactory: () => T) {
  const value = ref<T>(serverValue) as Ref<T>

  onMounted(() => {
    value.value = clientFactory()
  })

  return value
}

// 使用例
const timestamp = useHydrationSafeValue(
  '', // サーバー側では空文字列をレンダリング
  () => new Date().toLocaleDateString(),
)

2 種類目は環境判定による隔離:

vue
<template>
  <!-- サーバー側は常に fallback をレンダリング、クライアントマウント後に置換 -->
  <ClientOnly>
    <template #fallback>
      <div class="sidebar-placeholder">読み込み中...</div>
    </template>
    <ResponsiveSidebar :breakpoint="768" />
  </ClientOnly>
</template>

3 種類目が最も厄介だ。我々のアプローチは、サードパーティスクリプトを hydration 完了後に遅延読み込みすることだ:

typescript
// composables/usePostHydrationScript.ts
import { onMounted } from 'vue'

export function usePostHydrationScript(src: string) {
  onMounted(() => {
    const script = document.createElement('script')
    script.src = src
    script.async = true
    document.head.appendChild(script)
  })
}

デバッグツールチェーン:Vue Devtools 5.x の SSR パネルは mismatch ノードを直接ハイライトでき、コンソールログをめくるよりも遥かに効率的だ。開発環境で __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ コンパイルマクロを有効にすると、コンソールに具体的な expected vs actual の DOM 差分を出力する:

typescript
// vite.config.ts
export default defineConfig({
  define: {
    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: JSON.stringify(true),
  },
})

本番環境ではこのマクロを有効にしてはいけない。バンドルサイズが増加するからだ。

CDN Edge Streaming 統合 ​

我々の公開サイトは Cloudflare Workers にデプロイされており、エッジノードでストリーミング結合を行っている。アーキテクチャは以下の通り:

ユーザー → Edge Worker → [キャッシュ層] → Origin SSR Server
              ↓
     head + body チャンクのストリーミング結合
     edge-only コンテンツの挿入(地理位置情報、A/B テストマーカ)
              ↓
        ユーザーが最初のチャンクを受信

Edge Worker のコアロジック:

typescript
// cloudflare-worker.ts
export default {
  async fetch(request: Request) {
    const url = new URL(request.url)

    // 静的アセットはキャッシュ経由
    if (url.pathname.startsWith('/assets/')) {
      return caches.default.match(request) || fetch(request)
    }

    // SSR ページ:ストリーミングプロキシ
    const originUrl = `https://origin.example.com${url.pathname}`
    const originResponse = await fetch(originUrl, {
      headers: {
        'X-Forwarded-For': request.headers.get('CF-Connecting-IP') || '',
        'X-Geo-Country': request.cf?.country || '',
      },
    })

    // TransformStream を作成してエッジ注入を行う
    const { readable, writable } = new TransformStream()
    const writer = writable.getWriter()
    const reader = originResponse.body!.getReader()
    const decoder = new TextDecoder()
    const encoder = new TextEncoder()

    // 非同期で読み取り・変換
    ;(async () => {
      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        let chunk = decoder.decode(value, { stream: true })

        // </head> の前にエッジ変数を注入
        if (chunk.includes('</head>')) {
          const edgeData = `<script>window.__EDGE__=${JSON.stringify({
            geo: request.cf?.country,
            abGroup: getABGroup(request),
          })}</script>`
          chunk = chunk.replace('</head>', `${edgeData}</head>`)
        }

        await writer.write(encoder.encode(chunk))
      }
      await writer.close()
    })()

    return new Response(readable, {
      headers: {
        'Content-Type': 'text/html; charset=utf-8',
        'Transfer-Encoding': 'chunked',
        'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
      },
    })
  },
}

重要点:Edge Worker は Vue レンダリングを実行せず、ストリーミング転送と軽量な注入のみを行う。重い作業は Origin Server に任せる。これによりエッジノードの CPU オーバーヘッドは極めて低く抑えられ、グローバル P95 レイテンシは 80ms 以内に収まっている。

stale-while-revalidate 戦略と組み合わせることで、ほとんどのリクエストはキャッシュにヒットし、Origin はコンテンツ更新時のみアクセスされる。我々はコンテンツページに 60 秒の正キャッシュ + 300 秒の期限切れ利用可能を設定し、新記事公開後は Purge API で能動的にキャッシュをクリアしている。

CSR 内部システムから SSR 公開サイトへの移行:実際の経路 ​

我々の移行は一歩では完了せず、4 つのフェーズに分けた:

フェーズ 1:評価と選定(2 週間)

元のシステムは純 CSR の Vue 5 SPA で、社内ネットワークの Nginx にデプロイされていた。改修目標は公衆向けコンテンツサイトで、SEO とファーストビューのパフォーマンスが必要だった。Nuxt 3 と自作 SSR の 2 路線を評価し、最終的に自作を選択した――既存のルート構造と認証ロジックが比較的複雑で、Nuxt の規約ベースルーティングがかえって適応コストを増加させたためだ。

フェーズ 2:SSR インフラ構築(3 週間)

Node.js SSR サービスを構築し、renderToStream を導入し、共通依存のサーバー側互換性問題を処理した。最大の落とし穴はいくつかの UI ライブラリがサーバー側インポート時にエラーを出すこと(document にアクセスしてしまう)で、動的 import + 条件付き読み込みで解決した:

typescript
// サーバー側安全なコンポーネント読み込み
const DatePicker = defineAsyncComponent(() =>
  import.meta.server
    ? import('./DatePickerFallback.vue')
    : import('@ui-lib/date-picker'),
)

フェーズ 3:ページ単位の移行(6 週間)

トラフィック優先度でページを移行。まず記事詳細ページ(トラフィック最大、構造最も単純)で全リンクを検証し、次に一覧ページと検索ページ、最後にログイン状態が必要なマイページなどを処理した。各ページの移行後に 1 週間のグレーリリースを行い、CSR と SSR のパフォーマンス指標とエラー率を比較した。

フェーズ 4:Edge デプロイと最適化(2 週間)

Cloudflare Workers を導入し、キャッシュ戦略を設定し、A/B テストで SSR のコンバージョン率への影響を検証した。結果として LCP が 40% 低下し、検索エンジンのインデックス数は 3 週間で 200 から 8000 以上に増加した。

移行サイクル全体は 13 週間、2 名体制だった。最大の教訓は hydration mismatch のデバッグ時間を甘く見てはいけないということだ――我々は 1 週間を見積もっていたが、実際には 3 週間かかった。プロジェクト計画でこの部分に十分な余裕を持たせることを推奨する。

まとめ ​

2026 年における Vue SSR の核心的な変化は 3 つだ:renderToStream が TTFB ボトルネックを解決し、Vapor Mode の選択的 Hydration が全量 hydration のパフォーマンスペナルティを排除し、Edge Streaming がグローバルユーザー体験の均一化を実現した。2018 年の vue-server-renderer から現在に至るまで、SSR は「使えるが辛い」ソリューションからデフォルト推奨のレンダリングモードへと変貌した。移行の鍵は技術選定ではなく、hydration mismatch への十分な準備と漸進的な移行ペースのコントロールにある。もしあなたのチームがまだ純 CSR でコンテンツ型サイトを運営しているなら、今は切り替えの良いタイミングだ――ツールチェーンは多くの妥協を必要としないほど成熟している。

MIT Licensed