32blogby StudioMitsu
nextjs18 min read

Next.js Hydration Errorの完全解決ガイド

コンソールに真っ赤なHydration Errorが出たときの原因パターンと修正方法を完全解説。App Router対応。

nextjshydrationSSRApp Routerデバッグエラー解決
目次

デプロイ直前の最終確認でコンソールを開いたら、真っ赤なエラーが並んでいた。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 windowDate.now()Math.random() のような、サーバーとクライアントで返す値が異なるAPIをレンダリング中に直接使っている場合に起きる。

サーバー側では typeof window"undefined" になる。クライアント側では "object" になる。Reactがハイドレーション時に「サーバーは〇〇と言ったのにクライアントは〇〇と言っている」と判断してエラーを投げる。

❌ 悪い例: typeof windowの直接使用

tsx
// 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で遅延評価

tsx
"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()の直接使用

tsx
"use client";

export function Timestamp() {
  // サーバーでの実行時刻とクライアントでの実行時刻がずれる
  return <p>生成時刻: {new Date(Date.now()).toLocaleString()}</p>;
}

✅ 正しい例: useEffectで取得

tsx
"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+)を使う。

tsx
"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を変更されているのでどうしようもない。

tsx
"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>

tsx
// p要素はインライン要素しか子に持てない
// ブラウザが自動でpを閉じてdivを外に出す
export function Article() {
  return (
    <p>
      <div>これはNG</div>
    </p>
  );
}

❌ 悪い例: <a> の中に <a>

tsx
import Link from "next/link";

// アンカーのネストは禁止
export function NavItem() {
  return (
    <Link href="/page">
      <a href="/page">リンクテキスト</a>
    </Link>
  );
}

❌ 悪い例: <table> の直下に <tr>

tsx
// tableの直下にはtbody/thead/trfootが必要
export function DataTable() {
  return (
    <table>
      <tr>
        <td>データ</td>
      </tr>
    </table>
  );
}

✅ 正しい例

tsx
// 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-a11yeslint-plugin-react で多くの場合検出できる。設定していないプロジェクトなら入れておくといい。

bash
npm install --save-dev eslint-plugin-jsx-a11y eslint-plugin-react

レンダリング中にブラウザAPIを使うとどうなる?

App Routerのファイル構成を整理しているときに踏みがちなパターンだ。"use client" を付けたコンポーネントでも、Reactは一度サーバー側でもレンダリングを試みる(プリレンダリング)。そのためレンダリング中のブラウザAPI呼び出しはエラーになる。

❌ 悪い例: レンダリング中にlocalStorageを読む

tsx
"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で初期値を取得

tsx
"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を使う

tsx
// app/components/DeviceInfo.tsx
// "use client" がない = Server Component

export function DeviceInfo() {
  // Server ComponentではブラウザAPIは使えない
  const ua = navigator.userAgent;
  return <p>{ua}</p>;
}

✅ 正しい例: Client Componentに分ける

tsx
// 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 は以下の条件が揃ったときだけ使う。

  1. 自分のコードではなく外部(拡張機能、サードパーティライブラリ)がDOMを変えている
  2. そのミスマッチが機能上問題ない(単なる属性の差異で、UIや動作に影響しない)
tsx
"use client";

export function SafeHtml({ html }: { html: string }) {
  return (
    <div
      dangerouslySetInnerHTML={{ __html: html }}
      // サーバーとクライアントでHTMLが一致することを自分で保証できる場合のみ
      suppressHydrationWarning
    />
  );
}

逆に、「エラーがうるさいから」という理由だけで suppressHydrationWarning を使うのは避けること。根本原因が残ったままになり、将来的に予期しないバグの温床になる。

カスタムフックで「クライアントマウント済み」を管理する

クライアント専用のUIをあちこちで使う場合、毎回 useState + useEffect を書くのは面倒だ。useIsMounted フックを作っておくと再利用が楽になる。

tsx
// hooks/useIsMounted.ts
import { useState, useEffect } from "react";

export function useIsMounted(): boolean {
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
  }, []);

  return isMounted;
}
tsx
"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を無効にする

コンポーネント全体がクライアント専用で、サーバーでレンダリングする必要が一切ない場合は dynamicssr: false オプションが使える。

tsx
// 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 windowDate.now()Math.random()useEffectで遅延取得
拡張機能のDOM操作Grammarly、ColorZillaなどsuppressHydrationWarning を要素に付与
不正なHTML構造p > diva > atable > trHTMLの仕様に従った正しいネスト構造に修正
レンダリング中のブラウザAPIlocalStoragenavigator など"use client" + useEffectで隔離

まずエラーが出たらシークレットモードで確認して拡張機能の犯行を除外する。次にスタックトレースで問題のコンポーネントを特定する。そしてそのコンポーネントがサーバーとクライアントで異なる値を使っていないかチェックする。この順番でデバッグすれば、たいていのHydration Errorは30分以内に解決できる。

「サーバーで一度実行され、クライアントでも一度実行される」という前提でコンポーネントを書く習慣が身につけば、そもそもこのエラーを踏む頻度がぐっと下がる。