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)には localStorage や window オブジェクトが存在しないため、サーバーでエラーになるか、クライアントと異なる値になります。
対策: 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 エラー解決の一助になれば幸いです。
