デプロイ直前の最終確認でコンソールを開いたら、真っ赤なエラーが並んでいた。Hydration failed because the server rendered HTML didn't match the client ――見た瞬間に頭が真っ白になった経験が僕にはある。
サーバーサイドでは正常に動いているのに、ブラウザで開いた途端に壊れる。この症状はNext.jsのハイドレーション(hydration)の仕組みを理解していないと、何時間でもデバッグに溶かすことになる。
この記事では、Hydration Errorの根本的な仕組みから、実際によく踏む4つの原因パターン、そしてそれぞれの正しい修正方法まで、App Routerを前提に完全に解説する。読み終わったあとはHydration Errorを見ても慌てなくなるはずだ。
Hydration Errorとはどんなエラーか?
まず仕組みを理解しておく。Next.jsはSSR(サーバーサイドレンダリング)でHTMLを生成してブラウザに送る。ブラウザはそのHTMLをそのまま表示し、その後にReactがそのDOMに「ハイドレーション」を実行してインタラクティブにする。
ハイドレーションとは、サーバーが生成したHTMLの上に、ReactがJavaScriptのイベントリスナーや状態管理を「取り付ける」プロセスだ。このとき、ReactはサーバーのHTMLと自分が生成するVDOMを比較する。一致していれば問題ない。一致していなければ、それがHydration Errorだ。
エラーが出てもページ自体は表示される(Reactがクライアント側のHTMLで強制上書きするため)。だから「動いているように見えるけどコンソールにエラーが出る」という状況になる。これを放置すると、サーバーで生成した正しいHTMLが破棄されてSEOに悪影響が出ることがある。
サーバーとクライアントで値が違うとなぜエラーになる?
最も頻繁に踏むパターンだ。typeof window、Date.now()、Math.random() のような、サーバーとクライアントで返す値が異なるAPIをレンダリング中に直接使っている場合に起きる。
サーバー側では typeof window は "undefined" になる。クライアント側では "object" になる。Reactがハイドレーション時に「サーバーは〇〇と言ったのにクライアントは〇〇と言っている」と判断してエラーを投げる。
❌ 悪い例: typeof windowの直接使用
// app/components/ClientOnlyComponent.tsx
"use client";
export function ScreenWidth() {
// サーバーでは window が存在しないので undefined
// クライアントでは 1440 などの数値になる → ミスマッチ
const width = typeof window !== "undefined" ? window.innerWidth : 0;
return <p>画面幅: {width}px</p>;
}
✅ 正しい例: useStateとuseEffectで遅延評価
"use client";
import { useState, useEffect } from "react";
export function ScreenWidth() {
// 初期値はサーバーと同じ null にしておく
const [width, setWidth] = useState<number | null>(null);
useEffect(() => {
// useEffect はクライアントでしか実行されないので安全
setWidth(window.innerWidth);
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// マウント前はサーバーと同じ表示にする
if (width === null) return <p>画面幅: 読み込み中...</p>;
return <p>画面幅: {width}px</p>;
}
❌ 悪い例: Date.now()の直接使用
"use client";
export function Timestamp() {
// サーバーでの実行時刻とクライアントでの実行時刻がずれる
return <p>生成時刻: {new Date(Date.now()).toLocaleString()}</p>;
}
✅ 正しい例: useEffectで取得
"use client";
import { useState, useEffect } from "react";
export function Timestamp() {
const [timestamp, setTimestamp] = useState<string>("");
useEffect(() => {
setTimestamp(new Date().toLocaleString("ja-JP"));
}, []);
if (!timestamp) return <p>生成時刻: --</p>;
return <p>生成時刻: {timestamp}</p>;
}
Math.random() も同じ理由でレンダリング中には使えない。ランダムなIDや色をコンポーネント内で生成したい場合は、useEffectかuseIdフック(React 18+)を使う。
"use client";
import { useId } from "react";
export function FormField({ label }: { label: string }) {
// useId は SSR/CSR で同じIDを生成する(React 18+)
const id = useId();
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} type="text" />
</div>
);
}
ブラウザ拡張機能がHydration Errorを引き起こすことはある?
これは意外と見落としがちなパターンだ。ColorZilla、Grammarly、LastPassなど、特定のブラウザ拡張機能がDOMに属性や要素を追加することがある。
例えば、Grammarlyは data-gramm 属性をフォーム要素に追加する。これがサーバーのHTMLには存在しないので、ハイドレーション時にミスマッチが起きる。
エラーメッセージがこんな感じなら拡張機能が犯人である可能性が高い。
Warning: Prop `className` did not match.
Server: "input-field"
Client: "input-field grammarly-desktop-integration"
確認方法
シークレットモード(拡張機能が無効な状態)で同じページを開いてエラーが消えるかどうかを確認する。消えれば拡張機能が原因だ。
対処法: suppressHydrationWarningを要素に付与
この場合は suppressHydrationWarning が正しい使い方だ。自分のコードが問題ではなく、外部からDOMを変更されているのでどうしようもない。
"use client";
export function ContactForm() {
return (
<form>
<input
type="text"
placeholder="お名前"
// Grammarlyなどの拡張機能による属性変更を許可する
suppressHydrationWarning
/>
<textarea
placeholder="メッセージ"
suppressHydrationWarning
/>
</form>
);
}
suppressHydrationWarning は対象要素の1レベルのミスマッチだけを無視する。子要素のミスマッチには伝播しない点に注意。
不正なHTML構造がHydration Errorを引き起こすのはなぜ?
HTMLの仕様上、特定の要素は特定の子要素しか持てない。この制約に違反するコードを書くと、ブラウザが自動でDOMを修正するため、サーバーが生成したHTMLと実際のDOMが食い違う。
よくある違反パターンを挙げる。
❌ 悪い例: <p> の中に <div>
// p要素はインライン要素しか子に持てない
// ブラウザが自動でpを閉じてdivを外に出す
export function Article() {
return (
<p>
<div>これはNG</div>
</p>
);
}
❌ 悪い例: <a> の中に <a>
import Link from "next/link";
// アンカーのネストは禁止
export function NavItem() {
return (
<Link href="/page">
<a href="/page">リンクテキスト</a>
</Link>
);
}
❌ 悪い例: <table> の直下に <tr>
// tableの直下にはtbody/thead/trfootが必要
export function DataTable() {
return (
<table>
<tr>
<td>データ</td>
</tr>
</table>
);
}
✅ 正しい例
// pの中にはテキストやspanなどのインライン要素のみ
export function Article() {
return (
<div>
<p>これは正しい段落テキストです。</p>
</div>
);
}
// Next.js 13+のLinkはaタグを自動生成するので二重にしない
export function NavItem() {
return (
<Link href="/page">リンクテキスト</Link>
);
}
// tableには必ずtbodyを挟む
export function DataTable() {
return (
<table>
<tbody>
<tr>
<td>データ</td>
</tr>
</tbody>
</table>
);
}
この種のミスはESLintの eslint-plugin-jsx-a11y や eslint-plugin-react で多くの場合検出できる。設定していないプロジェクトなら入れておくといい。
npm install --save-dev eslint-plugin-jsx-a11y eslint-plugin-react
レンダリング中にブラウザAPIを使うとどうなる?
App Routerのファイル構成を整理しているときに踏みがちなパターンだ。"use client" を付けたコンポーネントでも、Reactは一度サーバー側でもレンダリングを試みる(プリレンダリング)。そのためレンダリング中のブラウザAPI呼び出しはエラーになる。
❌ 悪い例: レンダリング中にlocalStorageを読む
"use client";
export function ThemeToggle() {
// localStorage はサーバーには存在しない
// レンダリング中に呼ぶとサーバーでエラー or ミスマッチ
const savedTheme = localStorage.getItem("theme") ?? "dark";
const [theme, setTheme] = useState(savedTheme);
return (
<button onClick={() => {
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
localStorage.setItem("theme", next);
}}>
{theme === "dark" ? "☀️ ライト" : "🌙 ダーク"}
</button>
);
}
✅ 正しい例: useEffectで初期値を取得
"use client";
import { useState, useEffect } from "react";
export function ThemeToggle() {
// サーバーとクライアントで同じ初期値(null)を使う
const [theme, setTheme] = useState<"dark" | "light" | null>(null);
useEffect(() => {
// useEffect はクライアントでのみ実行されるので安全
const saved = localStorage.getItem("theme") as "dark" | "light" | null;
setTheme(saved ?? "dark");
}, []);
const toggleTheme = () => {
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
localStorage.setItem("theme", next);
};
// ハイドレーション前はボタンを非表示にする(チラつき防止)
if (theme === null) return null;
return (
<button onClick={toggleTheme}>
{theme === "dark" ? "☀️ ライト" : "🌙 ダーク"}
</button>
);
}
❌ 悪い例: Server Componentでnavigatorを使う
// app/components/DeviceInfo.tsx
// "use client" がない = Server Component
export function DeviceInfo() {
// Server ComponentではブラウザAPIは使えない
const ua = navigator.userAgent;
return <p>{ua}</p>;
}
✅ 正しい例: Client Componentに分ける
// app/components/DeviceInfo.tsx
"use client";
import { useState, useEffect } from "react";
export function DeviceInfo() {
const [ua, setUa] = useState<string>("");
useEffect(() => {
setUa(navigator.userAgent);
}, []);
if (!ua) return null;
return <p>{ua}</p>;
}
"use client" を付けただけでブラウザAPIが自由に使えるわけではない。コンポーネントがサーバーでも一度実行されることを常に意識して、ブラウザAPIはuseEffect内に隔離する習慣をつけると、このパターンで詰まることがなくなる。
Hydration Errorのデバッグ方法とsuppressHydrationWarningの使い方は?
エラーメッセージの読み方
Next.js 13以降のHydration Errorは以下のような形式で出る。
Unhandled Runtime Error
Error: Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-rendered component used:
- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()`.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it with the server.
- Invalid HTML tag nesting.
See more info here: https://nextjs.org/docs/messages/react-hydration-error
このエラーが出たら、まず「どのコンポーネントで起きているか」を特定する。エラーのスタックトレースを展開すると、コンポーネントのパスが確認できる。
Next.js 14以降では、開発モード(npm run dev)で開くと、問題のあるコンポーネントと差分が視覚的に表示される機能が入っている。
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
Expected:
<p data-theme="dark">
Received:
<p>
この差分情報が出たら、どの属性・要素が問題かがすぐわかる。
suppressHydrationWarningの正しい使い方
suppressHydrationWarning は以下の条件が揃ったときだけ使う。
- 自分のコードではなく外部(拡張機能、サードパーティライブラリ)がDOMを変えている
- そのミスマッチが機能上問題ない(単なる属性の差異で、UIや動作に影響しない)
"use client";
export function SafeHtml({ html }: { html: string }) {
return (
<div
dangerouslySetInnerHTML={{ __html: html }}
// サーバーとクライアントでHTMLが一致することを自分で保証できる場合のみ
suppressHydrationWarning
/>
);
}
逆に、「エラーがうるさいから」という理由だけで suppressHydrationWarning を使うのは避けること。根本原因が残ったままになり、将来的に予期しないバグの温床になる。
カスタムフックで「クライアントマウント済み」を管理する
クライアント専用のUIをあちこちで使う場合、毎回 useState + useEffect を書くのは面倒だ。useIsMounted フックを作っておくと再利用が楽になる。
// hooks/useIsMounted.ts
import { useState, useEffect } from "react";
export function useIsMounted(): boolean {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return isMounted;
}
"use client";
import { useIsMounted } from "@/hooks/useIsMounted";
export function ClientOnlyWidget() {
const isMounted = useIsMounted();
if (!isMounted) return <div className="skeleton" aria-hidden />;
return (
<div>
{/* ブラウザAPIを自由に使える */}
<p>画面幅: {window.innerWidth}px</p>
</div>
);
}
dynamic importでSSRを無効にする
コンポーネント全体がクライアント専用で、サーバーでレンダリングする必要が一切ない場合は dynamic の ssr: false オプションが使える。
// app/page.tsx
import dynamic from "next/dynamic";
// SSRを無効にする(クライアントでのみレンダリング)
const ClientOnlyWidget = dynamic(
() => import("@/components/ClientOnlyWidget"),
{
ssr: false,
loading: () => <div className="skeleton" />,
}
);
export default function Page() {
return (
<main>
<h1>ページタイトル</h1>
{/* このコンポーネントはサーバーでレンダリングされない */}
<ClientOnlyWidget />
</main>
);
}
ただし ssr: false を使うとサーバー側でHTMLが生成されないため、SEOが重要なコンテンツには向かない。
まとめ
Hydration Errorの原因は大きく4つに分類できる。
| パターン | 原因 | 解決策 |
|---|---|---|
| サーバー/クライアントで値が変わる | typeof window、Date.now()、Math.random() | useEffectで遅延取得 |
| 拡張機能のDOM操作 | Grammarly、ColorZillaなど | suppressHydrationWarning を要素に付与 |
| 不正なHTML構造 | p > div、a > a、table > tr | HTMLの仕様に従った正しいネスト構造に修正 |
| レンダリング中のブラウザAPI | localStorage、navigator など | "use client" + useEffectで隔離 |
まずエラーが出たらシークレットモードで確認して拡張機能の犯行を除外する。次にスタックトレースで問題のコンポーネントを特定する。そしてそのコンポーネントがサーバーとクライアントで異なる値を使っていないかチェックする。この順番でデバッグすれば、たいていのHydration Errorは30分以内に解決できる。
「サーバーで一度実行され、クライアントでも一度実行される」という前提でコンポーネントを書く習慣が身につけば、そもそもこのエラーを踏む頻度がぐっと下がる。