AI時代の記事執筆・更新・改善フロー|AIエージェントとCMSを連携した実運用
VIEW →
30cm靴図鑑の公開後、表示速度とページ遷移を見直しました。
最初に気になったのはTOPページの初回表示です。そこを軽くしたあとも、商品カードをタップすると「選択したカードが反応する → 画面が一瞬白くなる → 詳細ページが表示される」という現象が残りました。見た目だけなら、サーバーからデータを受け取ってページを作るSSRにも見えます。
しかし、実際の構成はSSGのままでした。原因はページの生成方式ではなく、ビルド済みのReact Server Components用データを配信するときのContent-Typeでした。
この記事では、GPT Sitesを使ったサイト制作の初期構成に続く改善記録として、初回表示の軽量化から原因の特定、失敗した対策、最終的な修正までをまとめます。
next/linkを使っていました。application/octet-streamで返り、vinextがクライアント遷移を中止して通常のページ読み込みへ切り替えていたことです。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/link、generateStaticParams、output: "export"などに対応しています。
このサイトではnext.config.tsにoutput: "export"を指定しています。商品詳細ではgenerateStaticParams()が全商品のslugを返し、dynamicParams = falseを指定しています。
実際のビルド結果は次の通りでした。
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とハイドレーションの対象は増えます。
そこで、次の調整を行いました。
CatalogShoeとして渡すload後のアイドル時間へ移すGoogle Analyticsの測定IDは変更していません。読み込み開始のタイミングだけを後ろへ移し、すべての公開HTMLに同じ計測設定が残ることを自動テストしています。
現在の公開TOPページは56,710バイトで、Content-Type: text/html、CF-Cache-Status: HITで返っています。初回表示はこの調整で改善しましたが、商品詳細へ移る瞬間の白い画面だけは残りました。
App Routerでは、URLを直接開いた最初の表示にはHTMLを使い、Linkによる次のページへの移動にはRSC Payloadを使います。RSC Payloadは、ページ全体のHTMLを読み直さず、次の画面へ更新するためのデータです。
このサイトでは、そのRSC Payloadもビルド時に静的ファイルとして生成されていました。したがって、遷移時に.rscへ通信が発生すること自体はSSRの証拠ではありません。静的に作られた遷移用データを取得しているだけです。
問題発生時にスマートフォン幅で操作を記録すると、商品カードを押したあとにURLが詳細ページへ変わり、document.readyStateがloadingへ戻り、本文の文字数が一時的に0になっていました。
これはReactの再描画によるちらつきではなく、新しいHTML文書を読み込む通常のページ遷移です。SSGのページを開く場合でも、ブラウザがページ全体を読み直せば白い時間は発生します。
next/linkやハイドレーションが原因ではなかった商品カードは最初からnext/linkで実装していました。Next.js公式でも、Linkはプリフェッチとクライアント遷移を提供する標準コンポーネントです。
また、商品カードを押す前にモバイル用の絞り込みボタンが開閉できることも確認しました。Reactのイベント処理はすでに動いていたため、「ハイドレーション前にリンクを押したので通常遷移になった」という説明には当てはまりません。
HTMLにも商品名、品番、説明、購入先が最初から含まれていました。詳細データがページ表示後に追加されていたわけでもありません。
最初に、商品、ブランド、記事の各リンクへprefetch={true}を追加しました。遷移先を先に読み込めば、白い時間を隠せると考えたためです。
しかし、使用していたvinext 0.0.50の自動プリフェッチは、動的ルートのパターンに一致するページを通常はフルプリフェッチしません。prefetch={true}を明示すると、画面内にある複数の商品、ブランド、記事のRSCをまとめて取得します。
その結果、TOPページの通信が増え、初回表示が修正前より重く感じられる状態になりました。この変更はすぐに撤回しました。プリフェッチは遷移用データを先に取る仕組みであり、誤った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-Typeがtext/x-componentから始まることを確認します。この条件を満たさない場合はwindow.location.hrefへフォールバックし、ページ全体を読み直します。
つまり、今回の流れは次の通りです。
Linkが詳細ページ用の静的RSCを取得するapplication/octet-streamで返るSSRに見えた正体は、RSC取得後に実行されたハードナビゲーションでした。
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でレスポンスヘッダーを上書きできます。そこで/*.rscへContent-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の |
|
|
商品データ | Markdownからビルド時に生成 | 変更なし |
ページ遷移 | HTML文書を再読み込み | RSCによるクライアント遷移 |
遷移中の |
|
|
遷移中の空画面 | 発生 | 0回 |
自動テスト | — | 20件すべて成功 |
スマートフォン幅390pxでTOPからHARUTAの商品詳細へ移動したテストでは、26ミリ秒時点ではTOPの内容が残り、372ミリ秒時点で詳細内容へ切り替わりました。計測中に本文が空になったサンプルは0回で、document.readyStateもcompleteのままでした。
公開中の詳細RSCを直接取得した結果も、HTTP 200、Content-Type: text/x-component; charset=utf-8、18,991バイト、CF-Cache-Status: HITです。RSCの内容を動的生成へ変えず、応答形式だけを直せたことを確認しています。
同じように「SSGのはずなのにページ遷移が白い」と感じた場合は、次の順で確認すると原因を切り分けやすくなります。
Linkを押したときにDocumentリクエストが発生していないか確認するContent-Typeを別々に確認するSSGかSSRかは、通信があるかどうかだけでは判断できません。いつHTMLと遷移用データが作られたのか、通信後にサーバーが何をしているのかまで確認する必要があります。
いいえ。商品、ブランド、カテゴリー、記事など47ルートをビルド時に生成するSSGです。公開後のリクエストごとに商品ページを生成していません。
一つのHTMLだけで全URLを動かす構成ではありません。各URLに静的HTMLがあり、最初の表示にはそのHTMLを使います。サイト内の次のページへ移るときは、LinkとRSCによるクライアント遷移を使います。
いいえ。検索と絞り込みは、ビルド時に作った商品一覧をブラウザ内で処理しています。検索時のAPI通信やページ生成はありません。
JSONに変えるだけでは速くなりません。同じ量のデータを同じタイミングで送れば負荷は同じです。今回は一覧に必要な項目だけへ絞り、静的部分をClient Componentの外へ出すことで初回処理を軽くしました。
next/linkを使っていなかったことが原因ですか?いいえ。商品カードには最初からnext/linkを使っていました。RSCのContent-Typeがvinextの期待と合わず、クライアント遷移から通常のページ読み込みへフォールバックしていたことが原因です。
移すだけでは直りません。今回の問題はCDNの名称ではなく、RSCのレスポンスヘッダーです。同じ静的ファイルを同じMIMEタイプで返せば、配信先を変えても同じフォールバックが起きます。
いいえ。Workerはビルド済みの静的RSCを読み、Content-Typeを付け直して返すだけです。商品データの取得やページ生成は行っていません。
今回のページ遷移は、SSGが遅かったのでも、検索データがあとから入っていたのでもありません。静的に生成されたRSCが誤ったContent-Typeで配信され、vinextがフォールバック処理としてHTML文書の再読み込みへ切り替えていました。
初回表示については、静的な表示部分をClient Componentから分離し、一覧用データを小さくし、WebフォントとGoogle Analyticsの読み込みを見直しました。遷移については、プリフェッチで隠すのではなく、RSCの配信を正しました。
SSG、RSC、クライアント遷移は同時に使われます。構成名だけで判断せず、ビルド成果物、公開レスポンス、ブラウザの状態を順に確認することが、今回の解決につながりました。
30cm靴図鑑の制作実績では、サイトの目的とデータ設計を紹介しています。初期構成から確認する場合は、GPT Sitesを活用したニッチなデータベースサイトの制作事例もあわせてご覧ください。
