← 記事一覧に戻る

Next.js App Router で "Hydration failed" エラーが解決できない・Text content does not match が出る時の5つの原因と対策

Next.js App Router で Hydration エラーが出てハマった

Next.js の App Router(v13〜)に移行したり、新規プロジェクトで使い始めたりすると、以下のようなエラーに遭遇することがあります。

Error: Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Text content did not match. Server: "..." Client: "..."

このエラーはコンソールに赤く表示され、場合によってはページ全体が白くなったり、UIが一瞬ちらついたりします。「エラーの意味は分かるが、どこで起きているのか特定できない」というケースも多く、解決に時間がかかりがちです。

この記事では、Hydration エラーが発生する5つの代表的な原因と、それぞれの具体的な対処法を解説します。

Hydration エラーとは何か

React の SSR(サーバーサイドレンダリング)では、サーバーでHTMLを生成してからクライアントに送り、ブラウザ側でそのHTMLにイベントリスナーを「付け直す」処理(Hydration)を行います。

このとき、サーバーで生成したHTMLとクライアントで生成したHTMLが一致しないと、React は整合性が取れないと判断してエラーを投げます。これが Hydration エラーです。

原因1: 日付・時刻をそのまま表示している

こんなコードで発生する

// NG: サーバーとクライアントで実行時刻が異なる
export default function Page() {
  return <p>現在時刻: {new Date().toLocaleString()}</p>
}

サーバーでレンダリングされた時刻と、ブラウザで Hydration される時刻がわずかにずれるため、テキストが一致しなくなります。

対策: useEffect + useState で クライアント側のみで表示する

'use client'

import { useEffect, useState } from 'react'

export default function CurrentTime() {
  const [time, setTime] = useState<string | null>(null)

  useEffect(() => {
    setTime(new Date().toLocaleString())
  }, [])

  if (time === null) return <p>読み込み中...</p>
  return <p>現在時刻: {time}</p>
}

初期値を null にしてサーバー側では何も(またはローディング表示を)出し、useEffect 内でクライアントのみの値をセットするパターンです。

原因2: Math.random() や crypto.randomUUID() を使っている

こんなコードで発生する

// NG: サーバーとクライアントで異なる値が生成される
export default function ListItem({ label }: { label: string }) {
  return (
    <li id={Math.random().toString(36).slice(2)}>
      {label}
    </li>
  )
}

Math.random() はサーバーとクライアントで別々に実行されるため、必ず異なる値になります。

対策: useId() フックを使う

React 18 から追加された useId() は、サーバーとクライアントで同じIDを生成します。

'use client'

import { useId } from 'react'

export default function ListItem({ label }: { label: string }) {
  const id = useId()
  return <li id={id}>{label}</li>
}

または、ランダムなIDが必要な場合は props や データベースの ID を使うように設計を見直しましょう。

原因3: localStorage / sessionStorage / window に直接アクセスしている

こんなコードで発生する

// NG: サーバー環境には localStorage が存在しない
export default function ThemeToggle() {
  const saved = localStorage.getItem('theme') // サーバーでクラッシュ
  return <div className={saved === 'dark' ? 'dark' : 'light'}>...</div>
}

サーバー環境(Node.js)には localStoragewindow オブジェクトが存在しないため、サーバーでエラーになるか、クライアントと異なる値になります。

対策: useEffect 内でアクセスする

'use client'

import { useEffect, useState } from 'react'

export default function ThemeToggle() {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')

  useEffect(() => {
    const saved = localStorage.getItem('theme')
    if (saved === 'dark') setTheme('dark')
  }, [])

  return <div className={theme === 'dark' ? 'dark' : 'light'}>...</div>
}

useEffect はクライアントでのみ実行されるため、安全に localStorage にアクセスできます。

原因4: ブラウザ拡張機能がDOMを書き換えている

症状

自分のコードには問題がないのにエラーが出る。シークレットウィンドウやブラウザ拡張を全て無効にすると消える。

翻訳系・広告ブロック・パスワードマネージャーなどのブラウザ拡張機能が、Hydration 後のDOMをこっそり書き換えることがあります。この場合、自分のコードにバグはなく、ユーザー環境固有の問題です。

対策: suppressHydrationWarning を使う

特定の要素(例: <html> タグの lang 属性や <body> タグ)だけ警告を抑制できます。

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja" suppressHydrationWarning>
      <body suppressHydrationWarning>
        {children}
      </body>
    </html>
  )
}

注意: suppressHydrationWarning は根本的な解決ではありません。自分のコードに起因するエラーを隠すために使うのは避け、拡張機能など外部要因による場合に限定して使いましょう。

原因5: 条件付きレンダリングのパターンが間違っている

こんなコードで発生する

'use client'

import { useState } from 'react'

export default function UserGreeting() {
  const [isLoggedIn, setIsLoggedIn] = useState(false)

  // NG: サーバー側の初期値(false)とクライアントの初期値が異なる場合
  return isLoggedIn ? <p>ようこそ!</p> : <p>ログインしてください</p>
}

このコード自体は問題ありませんが、親コンポーネントや Context から受け取る値がサーバーとクライアントで異なると Hydration エラーになります。

よくあるパターンは、Cookieや認証状態を使った条件レンダリングです。

対策: マウント後まで条件レンダリングを遅らせる

'use client'

import { useEffect, useState } from 'react'

export default function UserGreeting() {
  const [mounted, setMounted] = useState(false)
  const [isLoggedIn, setIsLoggedIn] = useState(false)

  useEffect(() => {
    // クライアントでのみ認証状態を確認
    const token = localStorage.getItem('auth_token')
    setIsLoggedIn(!!token)
    setMounted(true)
  }, [])

  if (!mounted) return null // サーバーとの一致を保証

  return isLoggedIn ? <p>ようこそ!</p> : <p>ログインしてください</p>
}

または、Next.js が提供する dynamic を使って SSR を無効化する方法もあります。

// components/UserGreeting.tsx(Client Component)
'use client'
export default function UserGreeting() {
  const isLoggedIn = !!localStorage.getItem('auth_token')
  return isLoggedIn ? <p>ようこそ!</p> : <p>ログインしてください</p>
}

// app/page.tsx(SSR無効でインポート)
import dynamic from 'next/dynamic'

const UserGreeting = dynamic(() => import('@/components/UserGreeting'), {
  ssr: false,
  loading: () => null,
})

ssr: false にすることで、そのコンポーネントはサーバーでレンダリングされなくなり、Hydration の不一致を根本から防ぎます。

デバッグの効率を上げるコツ

Hydration エラーはスタックトレースが分かりにくいことがあります。以下の手順でデバッグすると効率的です。

1. React DevTools の Strict Mode を一時的に外す

// app/layout.tsx
// StrictMode を外すと Hydration エラーの発生箇所が分かりやすくなることがある
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>{children}</body>
    </html>
  )
}

2. コンポーネントを二分探索で絞り込む

エラーが出るページのコンポーネントツリーを半分ずつコメントアウトして、どのコンポーネントが原因かを特定します。

3. ブラウザをシークレットモードで確認する

拡張機能の影響を排除して、自分のコードが原因かどうかを切り分けます。

まとめ

原因 対策
日付・時刻の不一致 useEffect + useState でクライアントのみで表示
Math.random() / UUID useId() フックを使う
localStorage / window useEffect 内でアクセスする
ブラウザ拡張機能 suppressHydrationWarning を使う(限定的に)
条件付きレンダリング mounted フラグか ssr: false を使う

Hydration エラーの根本的な解決策は「サーバーとクライアントで同じ値を生成する」か、「クライアント専用の値はマウント後にセットする」です。

エラーメッセージに該当コンポーネント名が含まれていることもあるため、コンソールのフルスタックトレースをよく読むことも大切です。

Next.js App Router は Pages Router と比べてサーバー・クライアントの境界が明確になった分、この問題に気を付けて設計する必要があります。この記事が Hydration エラー解決の一助になれば幸いです。