この記事でできること
Next.jsのApp Routerで、ページの一部を<Suspense>で分けると、待っている間はfallbackに渡した要素が表示されます。このfallbackの中にJSON-LD(構造化データ)を置くと、本体が届いた後も、初期HTMLにはフォールバックのJSON-LDと本体のJSON-LDが両方残ります。
個人開発している技術記事キュレーションサービスDevPickでは、記事詳細ページのパンくずを<Suspense>で分けていて、フォールバック(2階層)と本体(3階層)の両方がBreadcrumbListを出していました。
この記事では、フォールバックに何を置いてよいかの決め方と、本番の初期HTMLで二重出力を確かめる手順をまとめます。対象はNext.js 16・React 19のApp Routerです。
ストリーミングSSRでフォールバックが残る仕組み
<Suspense>の中身が最初の送信に間に合わないと、サーバーはまずフォールバックをHTMLに書き出します。本体は準備ができた時点で、同じレスポンスの後ろの方に<div hidden>として追加で送られます。ブラウザではReactがJSで本体をフォールバックの位置へ移し、フォールバックを消します。
つまり、画面に見えるのは本体だけでも、サーバーが返したHTMLには両方が入っています。DevPickの記事詳細ページの初期HTMLは、修正前はこのような並びでした(中身は省略しています)。
<!--$?--><template id="B:0"></template>
<script type="application/ld+json">{"@type":"BreadcrumbList", ... 2階層 ...}</script>
<nav aria-label="パンくずリスト">トップ > 記事タイトル</nav>
<!--/$-->
...
<div hidden id="S:0">
<script type="application/ld+json">{"@type":"BreadcrumbList", ... 3階層 ...}</script>
<nav aria-label="パンくずリスト">トップ > カテゴリ > 記事タイトル</nav>
</div>
B:0の位置にあるのがフォールバック、S:0の中にあるのが後から届いた本体です。JSを実行せずにHTMLを読むツールからは、BreadcrumbListが2つあるように見えます。
点検1:フォールバックがどのくらい出ているかを知る
フォールバックは「読み込みが遅いときだけ出るもの」と思いがちですが、待っているデータが最初の送信までに解決しなければ、毎回出ます。
-
<Suspense>の中で待っているデータが、ページの他の部分より遅く返ることが多いかを確認する - 開発環境はAPIが速く、フォールバックが一瞬で消えるので気づきにくい。本番のHTMLで確かめる
- 本番のHTMLに
<!--$?--><template id="B:0">があれば、その場所にフォールバックが書き出されている
点検2:フォールバックには見た目の要素だけを置く
フォールバックの中身は、本体に差し替えられた後も初期HTMLに残ります。そのため、ページに1つだけあるべきものは、フォールバックに置くと本体と二重になります。
- JSON-LDはフォールバックに置かず、本体だけが出す
- フォールバックは、本体と同じ形の見た目(DevPickではカテゴリを含まないパンくず)だけにする
- フォールバック用のコンポーネントを本体からコピーして作った場合は、構造化データを出す処理まで一緒に持ってきていないか確認する
DevPickでは、フォールバック用のパンくずコンポーネントからJSON-LDを出す処理を消し、見た目のパンくずだけを描画するようにしました。
点検3:本番の初期HTMLで、構造化データの数を数える
二重出力は、JSで差し替える前のHTMLにしかありません。curlでサーバーが返したHTMLをそのまま取得して数えます。
curl -s https://example.com/articles/123 \
| grep -o '"@type":"BreadcrumbList"' | wc -l
- 結果が1なら、BreadcrumbListは1つだけ
- 2以上なら、フォールバックと本体の両方が出している可能性が高い。
<div hidden id="S:0">の前後のどちらにあるかを見て、どちらが出しているかを切り分ける -
@typeの書き方(空白の有無など)は出力のしかたで変わるので、一度HTMLを目で見てから検索する文字列を決める
DevPickでは修正後、同じコマンドで記事詳細ページのBreadcrumbListが1つになったことを確認しています。残った1つは本体のもので、<div hidden id="S:0">の中にあります。
点検4:フォールバック単体のテストで、構造化データが無いことを固定する
画面の表示だけを確かめるテストでは、フォールバックが余計なJSON-LDを出していても通ってしまいます。
- フォールバックのコンポーネントを単体で描画し、
script[type="application/ld+json"]が0件であることを確かめる - 本体のコンポーネントでは、JSON-LDが1件だけで、条件に応じて階層の数が変わることを確かめる
まとめ
-
<Suspense>のフォールバックは、待っているデータが最初の送信に間に合わなければ毎回出る。本体に差し替えられた後も初期HTMLに残る - JSON-LDのように1ページに1つだけあるべきものは、フォールバックに置かず本体だけが出す
- 二重出力はJSを実行した後の画面には出ないので、
curlで取得した初期HTMLで数を確かめる - フォールバック単体のテストで、構造化データを出さないことを固定する
パンくずを<Suspense>で分けた経緯と、ほかの直し方を選ばなかった理由は、Zenn版に書いています。