Portfolio課題を言葉にして、Webというカタチに変える──
そんな仕事をしています。

GPT SitesのSSGサイトでページ遷移が白くなる原因|vinextのRSC配信を修正した記録

30cm靴図鑑の公開後、表示速度とページ遷移を見直しました。

最初に気になったのはTOPページの初回表示です。そこを軽くしたあとも、商品カードをタップすると「選択したカードが反応する → 画面が一瞬白くなる → 詳細ページが表示される」という現象が残りました。見た目だけなら、サーバーからデータを受け取ってページを作るSSRにも見えます。

しかし、実際の構成はSSGのままでした。原因はページの生成方式ではなく、ビルド済みのReact Server Components用データを配信するときのContent-Typeでした。

この記事では、GPT Sitesを使ったサイト制作の初期構成に続く改善記録として、初回表示の軽量化から原因の特定、失敗した対策、最終的な修正までをまとめます。

先に結論

  • サイトはSSRではなく、47ルートをビルド時に生成するSSGでした。
  • 商品情報はMarkdownからビルド時に取り込み、HTMLとRSCの両方へ出力しています。
  • キーワード検索はブラウザ内で既存データを絞り込む処理です。検索のたびにサーバーからデータを取得したり、新しいページを生成したりはしません。
  • 商品詳細へのリンクには、最初からnext/linkを使っていました。
  • ちらつきの原因は、静的RSCがapplication/octet-streamで返り、vinextがクライアント遷移を中止して通常のページ読み込みへ切り替えていたことです。
  • 静的RSCの内容は変えず、text/x-componentで返すようにしたことで、白い画面を挟まないクライアント遷移に戻りました。

調査時の構成

調査時の主な構成は、Next.js 16.2.12、React 19.2.8、vinext 0.0.50、Vite 8.2.0です。

ここで補足しておきたいのは、通常のnext buildをそのまま使っているわけではない点です。vinextは、Next.jsの公開APIをVite上に再実装するツールです。App Router、React Server Components、next/linkgenerateStaticParamsoutput: "export"などに対応しています。

このサイトではnext.config.tsoutput: "export"を指定しています。商品詳細ではgenerateStaticParams()が全商品のslugを返し、dynamicParams = falseを指定しています。

実際のビルド結果は次の通りでした。

  • 商品:21件
  • ブランド:12件
  • 記事:2件
  • 事前生成されたルート:47件
  • 生成を見送ったルート:0件
  • 遷移用に生成された静的RSC:46件

Next.js公式ドキュメントでも、output: "export"ではルートごとのHTMLが生成され、App Router内のServer Componentはビルド時に実行されると説明されています。動的ルートをビルド時に作るための仕組みがgenerateStaticParams()です。

検索ワードを入力したときに起きていること

商品データの元はcontent/shoes以下のMarkdownです。ビルド前の生成処理で内容を検証し、アプリケーション用の型付きデータへ変換しています。

TOPページの検索欄に文字を入力すると、ブラウザ内にすでにある商品一覧のsearchTextと照合します。ブランド、モデル名、品番、タグを小文字へそろえ、includes()で一致する商品を絞り込む単純な処理です。ブランド、靴の種類、ウイズ、価格上限も同じ一覧データに対して適用します。

この操作で外部APIやデータベースへ通信することはありません。新しいHTMLを作る処理もありません。商品詳細ページそのものは、検索前からビルド済みです。

データファイルをMarkdownからJSONへ変えても、同じデータ量を同じClient Componentへ渡すだけなら、表示速度はほとんど変わりません。重要なのはファイル形式ではなく、ブラウザへ渡す項目数、JavaScriptの量、初回表示で実行する処理です。

初回表示で先に見直したこと

最初は、TOPページのヒーロー、ブランド一覧、商品一覧、説明文まで、一つの大きなClient Componentに入っていました。

Client Componentだから、内容がすべてブラウザで後から生成されるわけではありません。App RouterではClient Componentも初回用HTMLへプリレンダーされ、JavaScriptが読み込まれたあとに操作できる状態へハイドレーションされます。ただし、Client Componentの境界が大きいほど、ブラウザへ送るJavaScriptとハイドレーションの対象は増えます。

そこで、次の調整を行いました。

  1. ヒーロー、ブランド一覧、一覧見出し、記事紹介、説明部分を静的なコンポーネントへ分離
  2. Client Componentには検索欄、絞り込み、並び替え、商品一覧の更新だけを残す
  3. 商品の詳細データをそのまま渡さず、一覧表示と検索に必要な項目だけをCatalogShoeとして渡す
  4. バンドルしていたNoto Sans JPを外し、初回に配信するWebフォントを0件にする
  5. Google Analyticsの読み込みをload後のアイドル時間へ移す
  6. タップ端末ではhoverアニメーションを実行しない

Google Analyticsの測定IDは変更していません。読み込み開始のタイミングだけを後ろへ移し、すべての公開HTMLに同じ計測設定が残ることを自動テストしています。

現在の公開TOPページは56,710バイトで、Content-Type: text/htmlCF-Cache-Status: HITで返っています。初回表示はこの調整で改善しましたが、商品詳細へ移る瞬間の白い画面だけは残りました。

SSGなのにSSRのように見えた理由

App Routerでは、URLを直接開いた最初の表示にはHTMLを使い、Linkによる次のページへの移動にはRSC Payloadを使います。RSC Payloadは、ページ全体のHTMLを読み直さず、次の画面へ更新するためのデータです。

このサイトでは、そのRSC Payloadもビルド時に静的ファイルとして生成されていました。したがって、遷移時に.rscへ通信が発生すること自体はSSRの証拠ではありません。静的に作られた遷移用データを取得しているだけです。

問題発生時にスマートフォン幅で操作を記録すると、商品カードを押したあとにURLが詳細ページへ変わり、document.readyStateloadingへ戻り、本文の文字数が一時的に0になっていました。

これはReactの再描画によるちらつきではなく、新しいHTML文書を読み込む通常のページ遷移です。SSGのページを開く場合でも、ブラウザがページ全体を読み直せば白い時間は発生します。

next/linkやハイドレーションが原因ではなかった

商品カードは最初からnext/linkで実装していました。Next.js公式でも、Linkはプリフェッチとクライアント遷移を提供する標準コンポーネントです。

また、商品カードを押す前にモバイル用の絞り込みボタンが開閉できることも確認しました。Reactのイベント処理はすでに動いていたため、「ハイドレーション前にリンクを押したので通常遷移になった」という説明には当てはまりません。

HTMLにも商品名、品番、説明、購入先が最初から含まれていました。詳細データがページ表示後に追加されていたわけでもありません。

プリフェッチを増やす対策はTOPを重くした

最初に、商品、ブランド、記事の各リンクへprefetch={true}を追加しました。遷移先を先に読み込めば、白い時間を隠せると考えたためです。

しかし、使用していたvinext 0.0.50の自動プリフェッチは、動的ルートのパターンに一致するページを通常はフルプリフェッチしません。prefetch={true}を明示すると、画面内にある複数の商品、ブランド、記事のRSCをまとめて取得します。

その結果、TOPページの通信が増え、初回表示が修正前より重く感じられる状態になりました。この変更はすぐに撤回しました。プリフェッチは遷移用データを先に取る仕組みであり、誤ったContent-Typeを直すものではありません。

原因は静的RSCのContent-Typeだった

原因を特定できたのは、遷移先のRSCをURLから直接取得し、レスポンスヘッダーと本文を分けて確認したためです。

修正前の詳細ページ用RSCは、次の状態でした。

HTTP 200
Content-Type: application/octet-stream
Body: 18,991 bytes

本文には商品名、品番、レイアウト情報が入り、RSCとして有効な内容でした。問題は本文ではなく、Content-Typeだけです。

使用していたvinext 0.0.50のクライアント処理は、RSCレスポンスが正常で、本文があり、Content-Typetext/x-componentから始まることを確認します。この条件を満たさない場合はwindow.location.hrefへフォールバックし、ページ全体を読み直します。

つまり、今回の流れは次の通りです。

  1. Linkが詳細ページ用の静的RSCを取得する
  2. RSCの本文は正しいが、application/octet-streamで返る
  3. vinextがRSCレスポンスとして受け付けない
  4. 通常のページ読み込みへ切り替える
  5. HTML文書を読み直す間、画面が一瞬白くなる

SSRに見えた正体は、RSC取得後に実行されたハードナビゲーションでした。

Cloudflareへ直接移しても、自動では直らない

Cloudflare Workersの静的アセット配信は、同じパスの静的ファイルがある場合、標準ではWorkerより先にそのファイルを返します。また、Wranglerはアップロード時に拡張子からMIMEタイプを判定します。

今回の公開環境では、.rscが静的アセットとして先に見つかり、application/octet-streamで返っていました。問題はCloudflareを使っていることではなく、静的アセットのMIMEタイプとvinextが求めるRSCの応答形式が一致していなかったことです。

今回の公開URLは、レスポンスヘッダーのserver: cloudflareとCF-Cache-Statusから、Cloudflare経由で配信されていることを確認しています。そのため、同じファイルと同じレスポンスヘッダーのまま別のCloudflare環境へ移しても、原因は残ります。移管ではなく、配信されるレスポンスを正す必要がありました。

途中で試した方法と、採用しなかった理由

_headersで上書きする

Cloudflare Workersの通常の静的アセット配信では、_headersでレスポンスヘッダーを上書きできます。そこで/*.rscContent-Type: text/x-componentを指定しました。

ただし、今回のSites公開後のレスポンスはapplication/octet-streamのままでした。この公開経路では、用意した_headersがRSCへ反映されていないことを本番レスポンスで確認し、採用をやめました。

run_worker_firstでRSCをWorkerへ通す

次に、.rscだけWorkerを先に通す設定を試しました。ローカルのCloudflare実行環境では意図通りに動きましたが、Sitesで公開すると静的な.rscが先に返りました。

標準のCloudflare Workersに同じ制限があるという意味ではありません。今回のSites公開パイプラインでは、設定だけで本番の配信順を変えられなかったという結果です。

.assetsignoreで除外する

.rsc.rsc.txtへコピーし、元の.rsc.assetsignoreで除外する方法も試しました。しかし、Sitesの公開物には元の.rscが残り、静的アセットとして先に返りました。

設定の解釈に頼るのではなく、ビルド結果から元の.rsc自体をなくす必要がありました。

最終的な修正

最終的には、ビルド済みのRSCを別のパスへ移し、.rscへのリクエストだけをWorkerで受ける構成にしました。

ビルド後の処理で、46件の.rsc.rsc.txtへ変更します。

const rscFiles = await collectRscFiles(clientDirectory);
await Promise.all(rscFiles.map((file) => rename(file, `${file}.txt`)));

これで公開物に元の.rscは存在しません。ブラウザが.rscへアクセスすると一致する静的アセットがないため、Workerがリクエストを受けます。

Workerでは.rsc.txtへ読み替え、CloudflareのASSETS bindingからビルド済みファイルを取得し、Content-Typeだけを正して返します。

if (url.pathname.endsWith(".rsc")) {
  const staticRscUrl = new URL(request.url);
  staticRscUrl.pathname += ".txt";
  const assetResponse = await env.ASSETS.fetch(
    new Request(staticRscUrl, request),
  );

  if (assetResponse.status !== 404) {
    const headers = new Headers(assetResponse.headers);
    headers.set("Content-Type", "text/x-component; charset=utf-8");

    return new Response(assetResponse.body, {
      status: assetResponse.status,
      statusText: assetResponse.statusText,
      headers,
    });
  }
}

Workerが商品データを取得したり、Reactを実行してページを生成したりはしません。ビルド済みの静的RSCを取得し、正しい応答形式で返すだけです。

HTML、CSS、JavaScriptは従来どおり静的アセットとして配信されます。RSC用のリクエストだけを限定してWorkerへ通しているため、サイト全体を動的配信へ変えたわけではありません。

修正後の確認結果

2026年8月15日に、公開環境とビルド結果を再確認しました。

確認項目

修正前

修正後

ページ生成

47ルートをビルド時に生成

変更なし

詳細RSCの本文

18,991バイト

18,991バイト

詳細RSCのContent-Type

application/octet-stream

text/x-component; charset=utf-8

商品データ

Markdownからビルド時に生成

変更なし

ページ遷移

HTML文書を再読み込み

RSCによるクライアント遷移

遷移中のreadyState

loadingへ戻る

completeを維持

遷移中の空画面

発生

0回

自動テスト

20件すべて成功

スマートフォン幅390pxでTOPからHARUTAの商品詳細へ移動したテストでは、26ミリ秒時点ではTOPの内容が残り、372ミリ秒時点で詳細内容へ切り替わりました。計測中に本文が空になったサンプルは0回で、document.readyStatecompleteのままでした。

公開中の詳細RSCを直接取得した結果も、HTTP 200、Content-Type: text/x-component; charset=utf-8、18,991バイト、CF-Cache-Status: HITです。RSCの内容を動的生成へ変えず、応答形式だけを直せたことを確認しています。

同じ症状を調べるときの確認順

同じように「SSGのはずなのにページ遷移が白い」と感じた場合は、次の順で確認すると原因を切り分けやすくなります。

  1. ビルド結果に対象URLのHTMLがあるか確認する
  2. HTMLソースに主要な本文が含まれているか確認する
  3. Linkを押したときにDocumentリクエストが発生していないか確認する
  4. RSCやルートデータのレスポンス本文とContent-Typeを別々に確認する
  5. フレームワーク側が不正なレスポンスを受けたときのフォールバック処理を確認する
  6. プリフェッチを増やす前に、遷移そのものが正常に成立しているか確認する
  7. ローカルだけでなく、実際の公開URLでレスポンスヘッダーを確認する

SSGかSSRかは、通信があるかどうかだけでは判断できません。いつHTMLと遷移用データが作られたのか、通信後にサーバーが何をしているのかまで確認する必要があります。

よくある質問

このサイトはSSRですか?

いいえ。商品、ブランド、カテゴリー、記事など47ルートをビルド時に生成するSSGです。公開後のリクエストごとに商品ページを生成していません。

SPAですか?

一つのHTMLだけで全URLを動かす構成ではありません。各URLに静的HTMLがあり、最初の表示にはそのHTMLを使います。サイト内の次のページへ移るときは、LinkとRSCによるクライアント遷移を使います。

検索するたびにデータを取得していますか?

いいえ。検索と絞り込みは、ビルド時に作った商品一覧をブラウザ内で処理しています。検索時のAPI通信やページ生成はありません。

商品データをJSONにすれば速くなりますか?

JSONに変えるだけでは速くなりません。同じ量のデータを同じタイミングで送れば負荷は同じです。今回は一覧に必要な項目だけへ絞り、静的部分をClient Componentの外へ出すことで初回処理を軽くしました。

next/linkを使っていなかったことが原因ですか?

いいえ。商品カードには最初からnext/linkを使っていました。RSCのContent-Typeがvinextの期待と合わず、クライアント遷移から通常のページ読み込みへフォールバックしていたことが原因です。

Cloudflareへ直接移せば直りますか?

移すだけでは直りません。今回の問題はCDNの名称ではなく、RSCのレスポンスヘッダーです。同じ静的ファイルを同じMIMEタイプで返せば、配信先を変えても同じフォールバックが起きます。

RSCをWorker経由にしたので、動的サイトになったのですか?

いいえ。Workerはビルド済みの静的RSCを読み、Content-Typeを付け直して返すだけです。商品データの取得やページ生成は行っていません。

まとめ

今回のページ遷移は、SSGが遅かったのでも、検索データがあとから入っていたのでもありません。静的に生成されたRSCが誤ったContent-Typeで配信され、vinextがフォールバック処理としてHTML文書の再読み込みへ切り替えていました。

初回表示については、静的な表示部分をClient Componentから分離し、一覧用データを小さくし、WebフォントとGoogle Analyticsの読み込みを見直しました。遷移については、プリフェッチで隠すのではなく、RSCの配信を正しました。

SSG、RSC、クライアント遷移は同時に使われます。構成名だけで判断せず、ビルド成果物、公開レスポンス、ブラウザの状態を順に確認することが、今回の解決につながりました。

30cm靴図鑑の制作実績では、サイトの目的とデータ設計を紹介しています。初期構成から確認する場合は、GPT Sitesを活用したニッチなデータベースサイトの制作事例もあわせてご覧ください。

参考資料

#
執筆者 | 西條輝
1995年生まれ。
Web業界は8年目です。それ以前は、実製品の品質管理などを5年ほど担当していました。
生成AIの進歩で「作ること自体のハードル」は下がったからこそ、現場の課題を丁寧に言葉にし、Webというカタチに落とし込むことが、これからますます重要になると感じています。
X(Twitter)
お問い合わせはこちら

Related Blog