Next.js App Router hydration error|テキスト不一致の原因を特定する手順

※本記事にはプロモーション(広告・アフィリエイトリンク)を含みます。
Next.js の App Router で「Hydration failed because the server rendered HTML didn't match the client」やテキスト不一致の警告が出ると、画面は表示されるのにボタンが効かない、といった不可解な挙動につながります。
この記事は、その原因をブラウザとコードから順番に切り分けて特定するための手順をまとめたものです。Next.js App Router + TypeScript で自作ECサイトを運用している中で整理した勘所を、再現できる形で書きます。バージョンや仕様は執筆時点(2026年9月)のものです。
hydration error(テキスト不一致)で何が起きているのか
App Router では、サーバー側で生成したHTMLをブラウザに送り、そのHTMLにReactが後からイベントハンドラなどを結び付けます。この結び付ける処理が hydration(ハイドレーション)です。
このとき、サーバーが出力したHTMLと、クライアントでの初回レンダリング結果が一致しないとReactが検出し、テキスト不一致の警告や「Hydration failed」エラーを出します。とくにテキスト内容(text content)が違う場合は「Text content does not match server-rendered HTML」という形で報告されます。
不一致が起きると、Reactは該当箇所を破棄してクライアント側で作り直します。その過程でイベントハンドラが期待どおりに繋がらず、表示は正しいのに操作が効かない、という状態になりえます。私自身、dev サーバーにLAN内のスマホからアクセスしたときに表示はできるのに onClick が発火せず、本番ビルド(next build && next start)では同じ経路で正常に動いた、という現象に遭遇したことがあります。原因はケースごとに異なりますが、「表示はできるのに反応しない」ときは hydration 周りをまず疑う価値があります。
不一致を起こす典型パターン
Next.js の公式ドキュメント(Hydration Mismatch のトラブルシュート)によると、テキスト不一致は次のような原因で起きやすいと整理されています。いずれも「サーバーとクライアントで出力が変わる」ことが共通点です。
window/localStorage/navigatorなどブラウザ専用APIを、レンダリング中(return内)で参照しているDate.now()/new Date()/Math.random()など、実行するたびに値が変わるものを描画しているtoLocaleString()などロケールやタイムゾーンに依存するフォーマットを使い、サーバーとクライアントで結果がずれる- 不正なHTMLネスト(
<p>の中に<div>、<table>直下にテキストなど)でブラウザが自動補正し、DOM構造が変わる - ブラウザ拡張やダークモード拡張が、初回描画前にDOMを書き換える
最後の拡張機能によるものはコード側の不具合ではないため、切り分けの段階で除外しておくと無駄な調査を減らせます。
手順:犯人を特定する
上の知識をもとに、次の順番で絞り込みます。
- エラーオーバーレイの差分を読む。 dev サーバーではエラー画面に「Server:」と「Client:」の食い違いが表示されます。どのテキストが違うのかをまず確認します。
- 該当コンポーネントを特定する。 オーバーレイのスタックやコンポーネント名から、どのファイルの描画かを絞ります。
- 変動要因をコードから探す。 疑わしいAPIをまとめて検索します。
grep -rn "Date.now\|new Date()\|Math.random\|localStorage\|window\.\|toLocaleString" app/ components/
- 仮説を固定値で検証する。 疑わしい箇所を一時的に固定文字列へ置き換え、エラーが消えるかを見ます。消えればそこが原因です。
- 拡張機能を除外する。 シークレットウィンドウ(拡張オフ)で再現するか確認します。再現しなければ拡張が原因です。
- HTML構造を疑う。 テキスト差分が見当たらないのに出る場合、
<p>や<table>の中身など不正ネストがないかを確認します。ここは環境やブラウザによって補正の挙動が異なります。
よくある修正パターンと使い分け
原因が分かったら、次のいずれかで対処します。全部を suppressHydrationWarning で握りつぶすのは避け、範囲を最小にするのが基本です。
| 原因 | 対処 | 適した範囲 |
|---|---|---|
| 時刻など1要素だけ差が出る | suppressHydrationWarning |
ピンポイント |
| ブラウザ専用APIに依存 | useEffect でマウント後に描画 |
単一コンポーネント |
| ライブラリ全体がSSR不可 | next/dynamic の ssr: false |
コンポーネント全体 |
| 不正なHTMLネスト | 正しいタグ構造に直す | 構造の修正 |
マウント後にだけ描画する定番パターンはこうです。
'use client'
import { useEffect, useState } from 'react'
export function ClientTime() {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null
return <span>{new Date().toLocaleString()}</span>
}
コンポーネントごとサーバー描画から外すなら next/dynamic を使います。
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), { ssr: false })
時刻表示など「ずれても実害がなく、1要素で完結する」ものに限れば、次のように属性で警告を抑制できます。ただしこれは差分を許容するだけで、根本原因を直すものではない点に注意してください。
<time suppressHydrationWarning>{new Date().toISOString()}</time>
まとめ
テキスト不一致の hydration error は、「サーバーとクライアントで出力が変わる箇所」を見つければほぼ解決できます。まずやるべき次の行動は1つ、dev サーバーのエラーオーバーレイで Server と Client の差分を読み、違っているテキストを特定することです。そこから上の手順で該当コードにたどり着き、範囲を最小にした対処を選んでください。
よくある質問
hydration error は本番ビルドでも出ますか?
原因が残っていれば本番でも発生します。ただしエラーオーバーレイは dev サーバーでしか出ないため、Server と Client の差分を確認する切り分けは開発環境で行うのが確実です。
suppressHydrationWarning を全体に付けても大丈夫ですか?
推奨されません。これは差分の警告を抑制するだけで根本原因は残ります。時刻など1要素に限定して使い、ブラウザ専用APIなどは useEffect や dynamic の ssr:false で正しく直すのが安全です。
表示は正しいのにボタンが効かないのも hydration が原因ですか?
可能性の一つです。不一致でReactが再描画するとイベントが繋がらないことがあります。原因はケースごとに異なるため、まずエラー表示と差分を確認して切り分けてください。