はじめに #
Next.jsでSSRやSSGを使っていると、誰しも一度は踏む Hydration failed because ... エラー。対処法は複数ある一方、SNSや解説記事は個別解法の紹介が中心で、「どういうときにどれを使うべきか」の全体像が見えにくいのが実情です。
本記事では、hydration mismatchの原因を5分類し、解決手段を5つ整理した上で、選び方を決定木にまとめた辞書型記事を目指します。迷ったときの早見表として使ってもらえれば幸いです。
📝 用語:本記事で「レンダリング」とは
Reactのコンポーネント関数を実行して HTML(または仮想DOM)を出力する処理 を指します。ブラウザがピクセルを画面に描画する工程(Paint)とは区別します。SSRならサーバーが、SSGならビルドマシンが、hydration時はブラウザがそれぞれ「レンダリング」を行います。
なぜ「hydration」と呼ぶのか #
hydrationという単語、語源的には「水を与える」という意味です。
語源 #
ギリシャ語 ὕδωρ (hydor) = 「水」が根っこ。そこから派生した英単語:
| 英単語 | 意味 | 関連 |
|---|---|---|
| hydration | 水分を与える/水和 | 本題 |
| hydrogen | 水素(=水のもと) | 化学 |
| dehydration | 脱水 | 医療・食品 |
| hydra | 水蛇(多頭の怪物) | ギリシャ神話 |
| hydrant | 消火栓 | 都市 |
全て同じ「水」の語根でつながっています。
日常での「hydration」 #
- 「Stay hydrated!」=「水分補給してね!」
- 化学の「水和反応」= 物質に水分子を結合させる
- スキンケアの「hydrating cream」= 保湿クリーム
いずれも「乾いているものに水を与えて本来の姿にする」イメージです。
ソフトウェア用語としてのhydration #
これは比喩です。
乾燥食品(骨だけ) + 水 = 戻った食品(食べられる)
SSRで作った静的HTML + JS = 動くReactアプリ
- サーバーが返すHTMLは「骨だけのビーフジャーキー」状態(見た目はある、でも動かない)
- ブラウザが JSバンドルを読み込んで各要素に挙動を紐付ける=「水を注いで元通り」
- ReactのイベントハンドラやuseStateの魂がDOMに宿る
語源としての「的確さ」 #
この比喩がソフトウェアで定着した理由:
- 見た目は同じまま中身が生き返る(DOM構造は変わらず、機能だけ追加)
- dry/wetの対比に馴染みがある(プログラミングではDRY原則も定着しているし、dry run・wet runの語感もある)
- 「乾燥 → 水分補給 → 生きる」という段階的な復活のニュアンス
「ただJSを後から実行する」と言うより、「乾いた骨に命を吹き込む」と言うほうが感覚的。Reactコミュニティが hydrate() という関数名を選んで広まった経緯もあり、今では一般用語化しています。
そもそもhydration mismatchとは何か #
サーバーレンダリングとhydrationの流れ #
まず、HTMLは事前にどこかで作られます(hydrationとは別のフェーズ)。
- SSR(Server-Side Rendering):リクエスト時にサーバー(Node等)が生成
- SSG(Static Site Generation):ビルド時にビルドマシンが生成 → CDN配信
ブラウザに届いたあとの流れは、SSR・SSGどちらも共通です。
- HTML受信・表示:受け取ったHTMLをブラウザが表示(Paint)。JSはまだ動いていない
- JSバンドル読込
- hydration:ブラウザのReactがコンポーネントをレンダリングして仮想DOMを作り、既存のDOMと突き合わせてイベントハンドラや状態を紐付ける(既存DOMの再生成はしない)
flowchart LR A["SSR: サーバーで生成
SSG: ビルド時に生成"] --> B["Browser
HTML受信・表示"] B --> C[JS読込] C --> D["hydration
イベントハンドラ紐付け
React内部状態構築"]
SSR/SSGの違いと個別化の選択肢(詳細)
SSR/SSGの違いは前半フェーズの「いつ誰が作るか」だけです。hydrationの仕組みはどちらでも同じなので、本記事の議論も両方に当てはまります。
| SSR | SSG | |
|---|---|---|
| HTML生成タイミング | リクエスト時 | ビルド時 |
| 生成場所 | サーバー(Node等) | ビルドマシン → CDN配信 |
| 動的ユーザーデータ | リクエストごとに変えられる(ログインユーザー名、カート内容等) | 全ユーザー共通のHTMLが返る(個別データはクライアント側で後から取得するか、middleware等で差し込む) |
SSGで「ユーザーごとに違う表示」が必要な場合の選択肢:
- ビルド時にはプレースホルダーを出し、hydration後にクライアントからAPIを叩いて差分を埋める
- Next.js の Middleware を使い、Cookie/Headerから読んでサーバー側で差分を注入する
個別化が大きすぎてSSGで捌ききれないなら、そのページは SSRに切り替える 判断も必要です。SSGは速い・安いが個別化しにくい、SSRは個別化できるがサーバーコストとレイテンシが増える、というトレードオフがあります。
※ Next.js App Router(RSC)はこの単純モデルより複雑ですが、Client Componentsのhydrationはここでのモデルとほぼ同じです。
mismatchとは #
hydrationの正体は、「サーバー生成HTML」と「ブラウザでReactが同じコンポーネントを再度レンダリングした結果」を突き合わせる作業です。両者がタグ・属性・テキストのどれかで食い違っていると mismatch になります。
ブラウザのReactは盲目的に再描画しているわけではなく、サーバーと同じ入力(コンポーネントのコード+props) で実行する前提で動きます。入力となるデータ(props・初期ステート)はHTMLに埋め込まれた JSON(Pages Routerなら __NEXT_DATA__、App RouterならRSC Payload)として届きます。
flowchart TD SC["コンポーネント関数 + props"] SC --> SR_SSR["SSR
リクエスト時に
サーバーがレンダリング"] SC --> SR_SSG["SSG
ビルド時に
ビルドマシンがレンダリング"] SC --> JS["JSバンドル
(bundlerがブラウザ向けに
コンポーネント関数を変換)"] SR_SSR -->|"即時配信"| H["HTML + 初期データJSON"] SR_SSG -->|"CDN経由で後日配信"| H H --> B["ブラウザ到達"] JS --> B B --> D[既存DOM] B --> R["ブラウザのReact
同じpropsでコンポーネント実行"] R --> V[virtual DOM] D --> C{比較} V --> C C -->|一致| OK[hydration成功] C -->|不一致| NG[mismatch]
入力(コード+props)が同じでも、コンポーネント内で入力以外のもの(new Date() / Math.random() / window / localStorage 等)を読むと、ブラウザでの再実行結果がサーバーから送られてきたDOMと一致しなくなります。これがmismatchの本質です。
SSR/SSGの違いは図の上部だけ。ブラウザ側は完全に同じです。ただしSSGはビルド時〜実行時の時間差が大きくなり得るため、new Date() のような時刻依存値はSSGでより顕著にmismatchを起こします(逆にビルド時点で確定する値しか使わないと割り切れば、SSGではmismatchを原理的に避けやすくなります)。
不一致の3パターン #
① テキストの不一致
function Now() {
return <p>{new Date().toISOString()}</p>;
}
// サーバー: <p>2026-04-19T10:00:00.000Z</p>
// ブラウザ: <p>2026-04-19T10:00:03.512Z</p>
// → テキストが違う
② 属性の不一致
function Card() {
const theme = typeof window !== 'undefined'
&& localStorage.getItem('theme');
return <div className={theme === 'dark' ? 'dark' : 'light'}>Hi</div>;
}
// サーバー: <div class="light">Hi</div> (localStorageが無い扱い)
// ブラウザ: <div class="dark">Hi</div> (localStorageに dark がある)
// → class属性が違う
③ 構造の不一致
function Banner() {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return isMobile ? <span>モバイル</span> : <div>デスクトップ</div>;
}
// サーバー: <div>デスクトップ</div>
// ブラウザ: <span>モバイル</span>
// → タグ自体が違う
何が問題か #
- Reactはmismatchを検知すると warning を出す(dev環境のみ表示)
- React 18以降:mismatchした部分をクライアント側で 再描画してリカバリ するが、チラつき・レイアウトシフトが起きる
- 内容によっては まったく別のDOMが出る(条件分岐で片方だけ描画等)→ 初期表示が壊れる
- パフォーマンスの無駄:サーバーで生成したHTMLが置き換えられる=サーバー側の描画コストが無効化される
- Core Web Vitals悪化:再描画で発生するCLS(レイアウトシフト)はGoogleの評価指標に影響し、間接的にSEOにも響く
原因パターン5分類 #
| # | パターン | 具体例 |
|---|---|---|
| 1 | ブラウザ専用API | window, localStorage, navigator |
| 2 | 時刻・ランダム値 | new Date(), Math.random(), crypto.randomUUID() |
| 3 | ユーザー固有表示 | OS由来のテーマ、ロケール、タイムゾーン |
| 4 | サードパーティDOM注入 | Google翻訳、ブラウザ拡張、広告タグ |
| 5 | 環境差異 | User-Agent、画面幅、Cookie有無 |
解決手段カタログ #
後述の決定木と同じ順序で並べます。上から順に「まず検討すべき」手段です。
| 手段 | 用途 | トレードオフ |
|---|---|---|
| レンダリング時に確定 | Cookie/Header から読む / ビルド時に埋め込む(mismatchを発生させない最良手) | 取得経路の見直し/middleware・header関数が必要 |
dynamic(..., {ssr:false}) |
このコンポーネントだけ事前レンダリングから外す | 初期表示が遅れる |
| useEffect + mountedフラグ | ブラウザ専用値の一部だけを差し替える | SSR時のコンテンツが空(or 既定値)になる |
suppressHydrationWarning |
時刻等の意図的なmismatch | 本来検知すべき別のmismatchも消えるリスク |
| 要件レベルで見直し | 他4手段がハマらないときに前提や構造を疑う(CC分割 / 要件再検討) | 設計変更が必要、影響範囲が広い |
決定木(この記事のキモ) #
まず疑うこと:この値は本当にクライアント固有か? #
hydration mismatchに当たったら、解決手段を探す前に一歩立ち止まりましょう。多くのケースで「本当はレンダリング時に確定できる値を、ブラウザ側で取ってしまっている」だけだったりします。
localStorageに保存していたテーマ設定 → Cookieに移せばレンダリング時に読めるnavigator.languageで判定していた言語 →Accept-Languageヘッダで読める- CSR時代のコードをそのままSSR化した結果 → 値の取得経路を見直す
レンダリング時に確定できるなら、それがmismatchを発生させない最良の手段です。決定木はこの問いを最初に置きます。
決定木 #
flowchart TD Q0["この値は本当にクライアント固有か?
(Cookie/Headerに移せないか?)"] Q0 -->|"NO(レンダリング時に確定可能)"| A0["レンダリング時に確定
(Cookie/Headerから読む
or ビルド時に埋め込む)
= mismatchを発生させない最良手"] Q0 -->|"YES(真にクライアント固有)"| Q1{このコンポーネントだけ
事前レンダリングから外してよい?} Q1 -->|YES| A1["dynamic(..., ssr:false)"] Q1 -->|NO| Q2{サーバー空表示を許容できる?} Q2 -->|YES| A2["useEffect + mountedフラグ"] Q2 -->|NO| Q3{mismatchを意図的に許容?
タイムスタンプ等} Q3 -->|YES| A3["suppressHydrationWarning
+ 理由コメント"] Q3 -->|NO| A4["要件・設計を見直し"]
最後に #
hydration mismatchは「Reactがサーバー/ビルド時とブラウザで、同じコンポーネントをレンダリングした結果が食い違う」現象です。根本原因は 入力(コード+props)以外の値 をレンダリング中に読んでしまうこと、この1点に尽きます。
よくある罠
suppressHydrationWarningの濫用:本来検知すべき別のmismatchまで握りつぶします。局所的に、かつ理由コメント付きで使いましょうtypeof window !== 'undefined'ガードで済ませる:サーバーのクラッシュは回避できますが、サーバー/ブラウザで出力が分岐するのでmismatchを自ら作り出している状態です。決定木の手段に置き換えましょう- localStorage と Cookieの取り違え:SSRで読みたい値はCookieへ。localStorage はブラウザ専用データに限定します
- 1つの大きなCCに複数の問題を抱える:各問題に別々の手段を当てるため、コンポーネント単位に決定木を走らせる のがコツです
hydration mismatch に出くわしたら決定木を参考にしてみて下さい!
Reply by Email