Next.jsのHydrationエラー、本当の原因はSVGの<title>だった【誤診から特定までの記録】
- この記事で分かること
- Hydrationエラーの原因を一度誤診し、切り分けをやり直して「SVGの<circle>の中に入れていた<title>」が原因だと特定するまでの手順
- 実際に使った環境
- Next.js 16.3.1(App Router)/ React 19.2.8。このブログの運用状況ページ(/results)の総資産チャートで実際に起きたエラーです
- 筆者の結論
- 原因の仕組みは完全には解明できていません。効いたのは、コンソールの見え方を疑うことと、コンポーネントを丸ごと外してから少しずつ戻す切り分けでした
このブログには、AI日本株自動売買の結果を公開している運用状況ページがあります。2026年9月14日、ここに総資産の推移を表す折れ線グラフを追加したところ、ページを開くとブラウザのコンソールにHydrationエラーが出るようになりました。
原因は、チャートのデータ点(SVGの<circle>)の中に入れていた、たった1行の<title>要素でした。ただ、そこにたどり着くまでに一度まったく別の場所を原因だと思い込み、的外れな修正をコミットしています。この記事では、その誤診も含めて、原因を特定するまでの手順をそのまま記録します。
🛠 実装してみて分かったこと
一番の反省点は、コードより先に「エラーの見え方」を疑うべきだったことです。複数のタブを行き来しながらコンソールを見ていたせいで、別のページで出たエラーを「全ページで出ている」と思い込み、サイト全体に関わるレイアウトの修正に走ってしまいました。1つのページを新しいタブで開き直して確認するようにしただけで、問題の範囲は一気に絞れました。
起きたこと
チャートを追加した直後、運用状況ページを開くとエラーが出ていることに気づきました。開発サーバーでページを開くと、コンソールに次の2種類のメッセージが出ていました。
Encountered a script tag while rendering React component...Hydration failed because the server rendered HTML didn't match the client...
Hydration(水和)は、サーバーで生成したHTMLに、ブラウザ側のReactが後からイベントなどを結び付ける処理のことです。サーバーで作ったHTMLと、ブラウザでReactが組み立てようとした内容が食い違うと、このエラーになります。
1回目の対処: 別の場所を原因だと思い込んだ
エラーメッセージに「script tag」とあったため、最初はlayout.tsxでテーマ設定のちらつき防止用に置いていたインラインスクリプトを疑いました。このブログではライト/ダークモード切り替えの実装で、Reactが動き出す前に保存済みの設定を<html>へ反映する小さなスクリプトを、生の<script>タグとして書いていました。
さらに、このときは本番ビルドでも/blogなどチャートと無関係なページを含めて全ページでエラーが再現していると判断していました。全ページに共通する場所といえばlayout.tsxです。そこで、生の<script>をNext.jsのnext/scriptコンポーネントに置き換えました。
// 変更前
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
// 変更後
<Script id="theme-init" strategy="beforeInteractive" dangerouslySetInnerHTML={{ __html: themeInitScript }} />strategy="beforeInteractive"は、ページが操作可能になる前(Reactの水和より前)に実行されるよう、初期HTMLに含めて出力する指定です。ちらつき防止スクリプトの用途には合っています。
しかし、この修正を入れても/resultsのエラーは消えませんでした。
誤診の原因: コンソールの見え方を疑っていなかった
エラーが消えないので一から調べ直したところ、前提そのものが間違っていたことが分かりました。
複数のタブでページを行き来しながら確認していたため、あるページで出たエラーのログが、その後に別のページへ移動した後も残って見えていたのです。「/blogでもエラーが出ている」と判断したのは、実際には/resultsで出たエラーを見ていただけでした。
そこで確認方法を変え、必ず新しいタブで1ページだけを開いてコンソールを見るようにしました。すると、/blogや/privacyは元々エラーが出ておらず、問題は/resultsだけで起きていることがはっきりしました。「全ページで再現する」という最初の報告は誤りでした。
注意
Hydrationエラーの調査では、「どのページで出ているか」の確認がそのまま原因の候補を決めます。ページをまたいでコンソールを見ていると、前のページのエラーが混ざって範囲を見誤ることがあります。確認するときは、1ページずつ新しいタブで開き直すのが確実です。
切り分け: コンポーネントを丸ごと外して、少しずつ戻す
/resultsだけの問題だと分かれば、容疑者はこのページにしか無いものに絞れます。ちょうど追加したばかりの総資産チャート(AssetHistoryChart)が最有力でした。
AssetHistoryChartを丸ごと描画しないようにする → エラーが消えた。原因はこのコンポーネントの中にあると確定- コンポーネントの中身を戻しながら、怪しい要素を1つずつ外す → データ点の
<circle>の中に入れていた<title>要素だけを削除したところ、エラーが消えた
いきなりコードを読んで原因を推理するより、「外したら消える」「戻したら出る」を確かめるほうが、確実に範囲を狭められます。
原因だったコード
チャートの各データ点には、マウスを乗せるとブラウザ標準のツールチップが出るよう、SVGの<title>を入れていました。
// 変更前(エラーが出ていた状態)
<circle
cx={p.x}
cy={p.y}
r={i === points.length - 1 ? 4 : 3}
fill={lineColor}
stroke="var(--surface)"
strokeWidth={1.5}
>
<title>
{p.day.date} {formatYen(p.day.totalAsset)}
</title>
</circle>これを、子要素を持たない<circle />に変えました。
// 変更後
<circle
cx={p.x}
cy={p.y}
r={i === points.length - 1 ? 4 : 3}
fill={lineColor}
stroke="var(--surface)"
strokeWidth={1.5}
/><title>を消しても、機能としては何も失っていません。このチャートでは、見た目の点のすぐ上に、マウスやキーボード操作を受け取るための透明な当たり判定用の円を重ねており、そちらに同じ内容のaria-labelを付けていました。スクリーンリーダー向けの情報はそちらで伝わります。ホバー時の見た目のツールチップも、<title>とは別に<div>で表示しているため、そのまま機能しています。
<circle
cx={p.x}
cy={p.y}
r={10}
fill="transparent"
onMouseEnter={() => setHoverIndex(i)}
onFocus={() => setHoverIndex(i)}
tabIndex={0}
role="button"
aria-label={`${p.day.isPrincipal ? "元本" : p.day.date}、総資産${formatYen(p.day.totalAsset)}`}
/>(実際のコードでは、日経平均・TOPIXの比較値もこのaria-labelに続けて読み上げるようにしています。)
つまり<title>は、もともと冗長な要素でした。
なぜ<title>でエラーになったのか(分かっていないこと)
正直に書くと、仕組みは完全には解明できていません。
React 19には、コンポーネントのどこに<title>を書いても、ページの<head>へ引き上げてくれる機能があります。ただしSVGの中の<title>はこの引き上げの対象外になるはずで、Reactのソースを追ったときも、SVG内の<title>を対象外として扱うコードの流れがあることは確認しました。それでも、このブログの構成(Next.js 16.3.1+React 19.2.8、クライアントコンポーネント内のSVG)では、この<title>を入れるとエラーが再現し、外すと再現しませんでした。
分かっているのは「この組み合わせで、この要素を外したら直った」という事実だけです。これ以上仕組みを追っても、得られるものに対して時間がかかりすぎると判断し、ここで調査を止めました。コード中のコメントにも、この経緯と「仕組みは未解明」であることを残しています。
1回目の修正は戻さなかった
誤診から入れたnext/scriptへの置き換えは、元に戻していません。テーマ初期化のようなインラインスクリプトをbeforeInteractiveで出力するのは、Next.jsが想定している書き方でもあり、動作にも問題が無いためです。
ただし、コミットの記録上は「Hydrationエラーの修正」として残ってしまっているので、2回目のコミットメッセージに「前回の修正は今回のエラーとは無関係だった」「全ページで再現するという報告は誤りだった」と明記して訂正しました。後から履歴を読んだときに、効いていない修正を「効いた修正」だと誤解しないためです。
今回の教訓
- エラーの範囲を確かめる方法から疑う: 複数タブをまたいでコンソールを見ていると、どのページのエラーなのかを見誤る。1ページずつ新しいタブで確認する
- 推理より切り分け: 疑わしいコンポーネントを丸ごと外す→中身を戻す、の順で「外したら消える」要素を探すほうが早くて確実
- 直前に何を足したかを思い出す: 今回はチャートを追加した直後に出たエラーだった。最初から「最後に足したもの」を疑っていれば、
layout.tsxへ遠回りせずに済んだ - 効かなかった修正は、効かなかったと記録する: 誤った修正を放置すると、後で履歴を読んだ人(自分を含む)がそれを正解だと思い込む
よくある質問
Q. SVGの<title>は使わないほうがいいのですか?
A. 一般論として使ってはいけないわけではありません。このブログのNext.js 16.3.1+React 19.2.8の構成では、クライアントコンポーネント内のSVGで<circle>の子に<title>を置いたときにHydrationエラーが再現し、取り除くと消えた、という事実を記録したものです。同じ内容をaria-labelで既に付けていたため、削除してもアクセシビリティは落ちませんでした。
Q. Hydrationエラーの原因箇所はどうやって絞り込みましたか? A. エラーが出ていたページで、怪しいコンポーネントを丸ごと描画しないようにしてエラーが消えるかを確認し、消えたらそのコンポーネントの中身を少しずつ戻していきました。いわゆる二分探索です。
Q. 最初に疑ったlayout.tsxの修正は元に戻しましたか?
A. 戻していません。テーマ初期化用のインラインスクリプトをnext/scriptのstrategy="beforeInteractive"で書くのは、Next.jsが想定している書き方でもあるためです。ただし、今回のHydrationエラーの原因ではありませんでした。
まとめ
Hydrationエラーの原因は、チャートのデータ点に入れていたSVGの<title>でした。遠回りした一番の理由はコードの難しさではなく、「どのページで起きているか」という前提を確かめずに修正へ進んだことです。エラーの範囲を正しく確かめ、コンポーネントを丸ごと外してから戻していく。地味ですが、この順番を守るのが結局一番の近道でした。