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

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

今回の記事は、技術的な「痛み」に共感する構成が非常に優れており、ターゲットとなる開発者の関心を引くフックが効いています。特に「なぜその解決策を選んだのか」という考察のセクションは、単なる技術解説を超えた質の高いコンテンツになっています。

以下、修正・推敲した【完成版の記事】です。


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

最近、Web制作の現場や個人開発の界隈において、Next.jsのApp Routerへの移行が加速しています。しかし、この過渡期において多くの開発者が直面する「見えない壁」があります。それがHydration Error(ハイドレーションエラー)です。

今回の記事では、私が最新スタックへの移行プロジェクトで実際に遭遇した「画面が真っ白になる」「コンソールに謎の警告が並ぶ」という絶望的な状況から、いかにして根本原因を突き止め、堅牢なコードへ昇華させたのか。その過程を、ありのままのドキュメンタリーとして記録します。


1. それは、深夜2時の「静かなる崩壊」から始まった

プロジェクトの初期段階では、すべてが順調に見えました。Next.jsのApp Routerを採用し、Server Componentsを最大限に活用することで、ビルド時間の短縮とパフォーマンスの向上を狙っていたのです。

しかし、ある特定のコンポーネント――ユーザーのブラウザサイズやOSを判定して動的にUIを出し分ける「レスポンシブ・トグル」を実装した瞬間、事態は急変しました。

開発環境では動いているように見えたのですが、デプロイ後のプレビュー環境で確認すると、コンソールに以下のような警告(あるいはエラー)が叩き出されたのです。

Warning: Text content does not match server-rendered HTML.
Expected: "Desktop View"
Actual: "Mobile View"

この瞬間、背筋に冷たいものが走りました。ブラウザのレンダリングとサーバーから送られてきたHTMLの不一致。これが「Hydration(ハイドレーション)」の問題です。最初は単なるタイポか、あるいはキャッシュのせいだと思い、原因を突き止めるために無駄な調査に数時間を費やしました。しかし、問題は表面的なものではなく、ReactのレンダリングサイクルとSSR(Server Side Rendering)の構造的齟齬にあったのです。

2. なぜ「Hydration Error」は発生するのか:技術的深掘り

この問題を解決するためには、「なぜこれが起きるのか」を正しく理解する必要があります。多くの初心者がここで「とりあえず`useEffect`で囲めばいいんでしょ?」と安易な解決策に飛びつきますが、それでは根本的な構造を理解できていません。

ハイドレーションの正体

Next.js(およびReact)におけるハイドレーションとは、サーバーから送られてきた静的なHTMLに対して、クライアント側でJavaScriptを実行し、イベントリスナーを付与したり、状態(State)を注入したりして「動的なページ」へと変貌させるプロセスを指します。

ここで極めて重要なのは、「サーバーが生成した最初のHTML」と「クライアントが最初にレンダリングしようとするDOM」が1ピクセルたりとも違っていてはならないという厳格なルールです。

衝突の構造

今回のケースでは、以下のような処理が行われていました。
  • サーバーサイド: ノード環境(またはEdge Runtime)で実行される。この時、`window`や`navigator`といったブラウザ固有のオブジェクトは存在しない。
  • クライアントサイド: ブラウザ上で実行される。
私が実装したコンポーネントは、以下のようなロジックを含んでいました。

// 失敗するコードの例(概念)
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;

export default function ResponsiveToggle() {
  return (
    <div>
      {isMobile ? "Mobile View" : "Desktop View"}
    </div>
  );
}

このコードの何が問題かというと、サーバー側でレンダリングされる際にも`isMobile`という変数が評価されるため、サーバーは「判断できない(あるいはデフォルト値)」としてHTMLを生成する。一方でクライアントは、ページがロードされた瞬間にブラウザの幅を見て「Mobile View」と書き換えようとする。

この「サーバーが作ったもの」と「クライアントがやりたいたこと」の差分を検知した瞬間、Reactはパニックを起こします。これがHydration Errorの正体です。

3. 代替技術との比較:解決策の選択肢

この問題を回避するためのアプローチはいくつか存在します。それぞれにメリットとデメリットがあるため、以下の表で整理します。

手法実装難易度UXへの影響推奨されるケース
useEffectによる状態同期低いわずかなレイアウトのちらつきが発生する可能性があるシンプルなUI要素や、サーバー側で判断不要な動的要素
Dynamic Import (ssr: false)中程度コンポーネントが読み込まれるまで「空」またはプレースホルダーになるブラウザ固有機能に強く依存する複雑なウィジェット
Cookie/Middlewareによる判定高いサーバーサイドで最初から正しい状態をレンダリングできるため、理想的なUXを提供ログイン状態やデバイス判定など、初期表示から確定すべき情報

4. 実践的な解決策:堅牢なコードへの昇華

私が最終的に採用した手法は、「クライアント専用のコンポーネントを動的インポートし、SSRの範囲外に追い出す」ことと、「ハイドレーションまでの状態を制御するカスタムフック」の組み合わせです。

パターンA:`next/dynamic` を活用した完全分離

ブラウザ固有のAPI(window, documentなど)を使用するコンポーネントは、そもそもサーバー側でレンダリングさせないという戦略です。

import dynamic from 'next/dynamic';

// クライアントサイドでのみ実行されることを保証する
const ClientOnlyComponent = dynamic(
  () => import('../components/ClientSideWidget'),
  { 
    ssr: false,
    loading: () => <p>Loading...</p> // ロード中のプレースホルダー
  }
);

export default function Page() {
  return (
    <main>
      <h1>最新技術のデバッグ日記</h1>
      <ClientOnlyComponent />
    </main>
  );
}

パターンB:Hydration状態を管理するカスタムフック(推奨)

「ちらつき」を最小限に抑えつつ、安全にクライアントサイドのみのロジックを組み込むためのより洗練されたアプローチです。

import { useState, useEffect } from 'react';

/**
 * クライアントサイドでのみ実行されることを保証するカスタムフック
 * サーバーサイドとクライアントサイドのハイドレーション間での不一致を防ぐ
 */
export function useHasMounted() {
  const [hasMounted, setHasMounted] = useState(false);

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

  return hasMounted;
}

// 利用例
export default function ResponsiveToggle() {
  const hasMounted = useHasMounted();

  // マウントされるまでは何も表示しない、あるいはプレースホルダーを表示する
  if (!hasMounted) {
    return <div className="loading-skeleton" />; 
  }

  // ここから下はクライアントサイドでのみ実行されることが保証される
  const isMobile = window.innerWidth < 768; // ここでwindowを触っても安全
  
  return (
    <div className="toggle-container">
      {isMobile ? "モバイル用表示" : "デスクトップ用表示"}
    </div>
  );
}

5. なぜこの解決策を選んだのか(考察と反省)

正直に言えば、最初の数時間は「なぜ`useEffect`で囲むだけではダメなのか?」という疑問に苛まれた。しかし、深く調査するうちに、「Reactは、意図しないリレンダリングを検知する非常に厳格なフレームワークである」という事実に突き当たったのです。

もし単純に`useEffect`だけで解決しようとすると、サーバーから届いた直後の瞬間(ハイドレーション中)にDOMの不一致が発生し、ブラウザによっては一瞬だけ「Desktop View」と表示されてから「Mobile View」に切り替わるようなLayout Shift(レイアウトのズレ)が発生します。これはUXを著しく損なうだけでなく、Googleのコアウェブバイタル(CLS)にも悪影響を与える可能性があります。

したがって、`next/dynamic` を用いてSSR対象から外す手法は、「確実な隔離」を求める場合に最適であり、今回のような「ブラウザ幅に合わせたUIの切り替え」には、クライアントでのマウント確認を挟むのが最も堅牢な設計であると判断しました。

6. まとめ:技術の裏側にある思想を知る

今回のトラブルシューティングを通じて学んだのは、「エラーメッセージは常にヒントを含んでいる」ということです。Hydration Errorが出たとき、それは単にコードが間違っていることを警告しているのではなく、「あなたの書いたコードが、Webのレンダリング原理(サーバーとクライアントの境界)を無視している」という構造的な問題を指摘しているのです。

最新の技術スタック(Next.js App Router, React Server Components等)を扱うことは、単に新しい関数を覚えることではありません。その背後にある「なぜこの制約があるのか?」という設計思想を理解することなのです。

次に同じエラーに直面するエンジニアがいたら、私は自信を持ってこう伝えるでしょう。
「それは君のコードが悪いんじゃない。レンダリングの境界線を意識した設計が必要なだけだよ。」


🛠 今回学んだこと・リファレンス

  • Hydration Error: ServerとClientの初期状態の不一致。
  • Dynamic Import: `ssr: false` を使うことで、サーバー側での実行を物理的に遮断する強力な武器。
  • Layout Shift: 実用的な開発において、いかに「ちらつき」を抑えるかが品質に直結する。