【絶望】Next.js App Router移行で「Hydration Error」の沼にハマり、3時間溶かした実録。

技術ブログおよびガジェットレビューの専門編集者として、ご提示いただいたドラウザ記事を推敲・校正いたしました。

読者の共感を呼び起こすエモーショナルな語り口を維持しつつ、技術解説としての正確性と読みやすさを向上させています。特に「なぜダメなのか」という理論パートと、実用的な解決策の対比を強調する構成に整えました。


【絶望】Next.js App Router移行で「Hydration Error」の沼にハマり、3時間溶かした実録。

最近、Web開発のトレンドとして避けては通れないのがNext.jsのApp Routerへの移行でしょう。サーバーコンポーネント(RSC)の導入によって、データ取得のあり方やレンダリングの仕組みが劇的に変わったのは非常にエキサイティングな変化です。しかし、その「新しさ」の裏には、以前のPages.js時代にはあまり意識しなくて済んでいた「Hydration(ハイドレーション)」に関する地雷原が広がっていることを、私は身をもって知らされました。

この記事は、私がNext.js 14を導入したプロジェクトで遭遇した、「なぜか原因不明のHydrationエラーが出て、ブラウザで表示されない」という絶望的な状況をどう突破したかのドキュメントです。

単に「解決策はこれです」と提示するだけではありません。なぜその問題が発生するのか、内部構造はどうなっているのか、そして代替案と比較してどの手法を選ぶべきか。技術的背景を徹底的に深掘りしていきます。


1. 何が起きたのか:真っ白な画面と怒りのコンソール

事の始まりは、ユーザーの現在地やブラウザの言語設定を判定して、動的にヘッダーの表示を切り替える機能を実装しようとした時でした。

具体的には、以下のような処理を記述しました。

// 失敗したコードの例
import { useState, useEffect } from 'react';

export default function Header() {
  // ブラウザにしか存在しない window オブジェクトを参照しようとして自爆
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    setIsMobile(window.innerWidth < 768);
  }, []);

  return (
    <header>
      {isMobile ? <MobileMenu /> : <DesktopMenu />}
    </header>
  );
}

一見、`useEffect`の中で処理を行っているため安全に見えるかもしれません。しかし、Next.js(およびReact)のレンダリングサイクルを正確に把握していないと、このコードは「Hydration Error」という名の地雷を踏むことになります。

エラーの内容

ビルドは通ります。開発サーバーも正常に起動します。しかし、ブラウザでページをリロードした瞬間に、コンソールには真っ赤な警告(あるいはエラー)が突き刺さります。

> Text content does not match server-rendered content. The server rendered "DesktopMenu" but the client rendered "MobileMenu".

このエラーが発生すると、最悪の場合、Reactはクライアント側でのレンダリングを放棄したり、UIがチカチカと点滅する(Hydration Mismatch)といった挙動を見せたりします。何より「なぜこれが起きるのか」という理屈を理解せずにコードを書くと、デバッグの際に数時間を無駄にすることになります。


2. なぜこのエラーは発生するのか(内部構造の深掘り)

ここで少しだけ技術的な背景を深掘りしましょう。Next.jsのApp Routerにおいて、コンポーネントはまずサーバー側でHTMLとして生成されます。これがSSR (Server Side Rendering) です。

次に、ブラウザがそのHTMLを受け取り、JavaScriptを実行してReactのイベントハンドラや状態(State)を紐付けるプロセスをHydration(ハイドレーション)と呼びます。

この時、Reactは以下の2つを比較します。
1. サーバー側で生成された初期HTML構造
2. クライアント側で最初にレンダリングされた結果

この2つが「完全に一致」していなければなりません。

今回のケースで問題なのは、`isMobile` の初期値です。

  • サーバー側: `useEffect` は実行されないため、`isMobile` は常に初期値の `false`(DesktopMenu)としてレンダリングされます。

  • クライアント側: Reactがマウントされる瞬間、JavaScriptによって `window.innerWidth` をチェックする準備が行われます。


しかし、Reactの最初のパス(初回レンダリング時)において、もしサーバーとクライアントで初期状態に差異があれば、この比較は失敗します。特に、`new Date()` や `Math.random()`、あるいは今回の「ブラウザ固有のプロパティ」を直接参照して初期値を決定する処理は、「サーバーでは判定不能だが、クライアントでは確定する」ため、不一致を引き起こす原因の筆頭となるのです。


3. 解決策の比較と選定基準

この問題を解決するためのアプローチはいくつか存在します。それぞれにメリット・デメリットがあるため、状況に応じて最適な手法を選択する必要があります。

手法内容メリットデメリット推奨されるケース
useEffect + State初期状態を共通にし、マウント後に状態を更新する実装が標準的で理解しやすい。初回レンダリングと再描行の間に「ちらつき」が生じる可能性がある。ローカルストレージなどブラウザ固有の機能を使う場合。
Dynamic Import`next/dynamic` で `ssr: false` を指定し、クライアントのみで読み込む。確実にサーバーとの不一致を回避できる。コード分割が発生し、微細な影響を与える可能性がある。地図や動画など、完全にブラウザ専用のコンポーネントの場合。
Middlewareリクエスト検知段階(Edge等)で判定を行う。サーバーとクライアントの不一致を根本的に回避できる。Cookie解析等が必要なため、実装の複雑度が上がる。言語設定や地域によるリダイレクトなど。
Server Componentsデータ取得やロジックを可能な限りサーバー側で完結させる。Hydrationの問題自体が発生しない(Next.jsの推奨流儀)。インタラクティブな要素は結局Client Componentが必要になる。データの取得、基本構造の構築など。

4. 実践的な解決策の実装例

では、実際に私がこのプロジェクトで採用した「正攻法」と「回避策」をコードで確認していきましょう。

パターンA:`useEffect` を使った安全なクライアントサイド判定

これが最も一般的ですが、「初期状態(Initial State)をサーバーと共通にする」ことが鉄則です。

"use client"; // クライアントコンポーネントとして定義

import { useState, useEffect } from 'react';

export default function SafeHeader() {
  // 初期値は必ずサーバーでもレンダリング可能なもの(ここではfalse)にする
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    // マウントされた時点でtrueにする。これにより、
    // 初回レンダリングとクライアントでの再描画の不一致を回避する。
    setIsMounted(true);
  }, []);

  // マウントされるまで共通のUI(プレースホルダー)を返すことでHydrationエラーを防ぐ
  if (!isMounted) {
    return <div className="h-16 w-full bg-gray-100" />; 
  }

  // マウントされた後であれば、ブラウザ固有の情報を基にレンダリングを行う
  const isMobile = window.innerWidth < 768;
  return isMobile ? <MobileMenu /> : <DesktopMenu />;
}

パターンB:`next/dynamic` による強制的なSSR除外

「プレースホルダーを見せるのがデザイン的に許容できない」場合に有効な手法です。

import dynamic from 'next/dynamic';

// サーバーサイドでのレンダリングを無効化してインポートする
const DynamicMenu = dynamic(() => import('@_components/DynamicMenu'), {
  ssr: false,
});

export default function Header() {
  // このコンポーネントはクライアント側でしか実行されないため、
  // サーバーとの比較(Hydration)の対象から外れる。
  return (
    <header>
      <DynamicMenu />
    </header>
  );
}


5. まとめ:なぜ「とりあえず動く」ではいけないのか

今回のトラブルシューティングを通じて痛感したのは、「技術的な制約を理解せずに便利な機能だけを組み込むと、必ず後でツケが回ってくる」という事実です。

Hydrationエラーは単なる「表示のバグ」ではありません。それは、Reactがサーバーとクライアントの間で整合性を保とうとしている仕組みに対する警告です。この警告を無視して `||` や `&&` で強引に解決しようとすると、SEOへの悪影響やアクセシビリティの低下など、目に見えない不具合を引き起こす可能性があります。

最後にアドバイス

Next.js(App Router)で開発する際は、以下の優先順位で設計を検討することを強く推奨します。

1. できるだけ Server Component で完結させる。 (データの取得、静的なコンテンツなど)
2. クライアントの操作が必要な場合のみ Client Component を使う。
3. Client Component 内で「ブラウザ固有の情報」を扱う場合は、必ず `useEffect` や `dynamic(..., { ssr: false })` でラップする。

この基本原則を守るだけで、私のような悲惨な「数時間の消失」を防ぐことができるはずです。


💡 学習のポイント

  • Hydrationとは: サーバーから送られてきたHTMLに、クライアント側でJavaScriptを紐付ける工程のこと。
  • 不一致の原因: `window` や `localStorage` など、サーバー環境には存在しない情報を初期レンダリングに使用した時。
  • 解決の鉄則: 「サーバーとクライアントで同一の結果を出す」ことを最優先する。