Next.js App Routerの「Hydration Error」と「Server Actionsの挙動不一致」に絶望した僕が辿り着いた正解

専門の編集者として、ご提示いただいたドラフトを精査いたしました。

今回の記事は、Next.jsを採用するエンジニアが必ず直面する「Hydration Error」という技術的負債に対する非常に実用的な内容です。読者の共感を得るためのエモーショナルな導入から、構造的な原因分析、そして具体的な解決策へと繋げる構成を活かしつつ、よりプロフェッショナルな技術ブログとして洗練させた【完成版】を以下に提示します。


Next.js App Routerの「Hydration Error」と「Server Actionsの挙動不一致」に絶望した僕が辿り着いた正解

Next.jsからPages Routerへの移行、あるいは新規プロジェクトでのApp Router採用。このあたりを検討しているエンジニアなら、一度は必ず突き当たる壁があります。それが「Hydration Error」です。

「なぜかローカル環境では動くのに、デプロイした瞬間にエラーが出る」「SSR(Server Side Rendering)を意識した途端に、ブラウザのコンソールが真っ赤になる」。こうした問題に頭を抱えながら、結局 `useEffect` の中で処理を回すという「とりあえず動けばいい」という逃げの手法を取っていませんか?

今回は、中堅エンジニアとして数々の現場でこの壁にぶつかってきた実体験をもとに、App Routerにおけるレンダリングの仕組み、Hydrationエラーの根本原因、そしてServer Actionsを用いた状態管理における落とし穴を徹底的に深掘りします。単なる表面的な「解決策」の羅列ではなく、「なぜそうなるのか」という内部構造まで踏み込んで解説します。


1. なぜ「Hydration Error」は発生するのか

まず前提として、Next.js(特にApp Router)におけるHydrationの仕組みを正しく理解する必要があります。

ReactのHydrationとは、サーバー側で生成されたHTMLに対して、クライアント側でJavaScriptを実行し、Reactの内部状態とDOMを紐付けるプロセスを指します。このとき、「サーバーが生成したHTML」と「クライアント側で最初にレンダリングされる内容」が1ビットでも異なっていれば、Hydration Errorが発生します。

よくある原因の分類

具体的にどのようなケースでこれが起きるのか、主な要因を以下の表にまとめました。
カテゴリ具体的な要因発生する主な場面解決へのアプローチ
環境依存`window` や `localStorage` へのアクセスブラウザ固有のAPIを利用する場合`useEffect` 内での実行、または Dynamic Import を活用
動的データ現在時刻(`new Date()`)やランダム値サーバーとクライアントで時間のズレが生じる場合サーバー側で生成した値をPropsとして渡すか、クライアントで確定させる
不整合なタグ`<p><div_tag>...</div></p>` 等のHTML違反ブラウザの自動修正機能が介入する場合HTML仕様に則った正しいマークアップへの修正
マクロの差異ユーザーの言語設定やCookieの有無特定のセッションのみで変化する内容Middlewareでの処理、または Client Component 化を検討

特に厄介なのが「現在時刻」です。サーバーがレンダリングした瞬間と、ブラウザがJavaScriptを実行して描画を確定させる瞬間のわずかなタイムラグにより、`14:00:01` と `14:00:02` のような差が生じ、Hydrationエラーを引き起こします。

2. Client Componentへの安易な逃避をやめる

多くのエンジニアが陥りやすい罠の一つに、「Hydration Errorが出るから、とりあえず全部 `'use client'` にしてしまおう」という思考があります。

確かに、すべてのコンポーネントをクライアントサイドで処理すればエラーは消えます。しかし、それはNext.jsの最大の利点である「SEOの最適化」と「初期表示速度(FCP/LCP)」の向上を放棄することと同義です。

App Routerの思想は、「できるだけServer Componentで処理し、インタラクティブな部分だけをClient Componentに切り出す」ことにあります。この境界線をどこに引くかが、技術的な腕の見せ所です。

適切な「クライアントへの委ね方」

例えば、ボタンのクリックイベントやフォームの入力制御が必要な箇所は当然 `use client` を使いますが、その中身すべてをクライアントで動かす必要はありません。
  • 失敗例: ページ全体を `use client` にし、その中で `useState` や `useEffect` を使ってデータをフェッチする。これでは初期表示時に「読み込み中...」のスケルトンが表示されることになり、ユーザー体験(UX)を損なう可能性があります。
  • 推奨される設計: データ取得は Server Component で行い、その結果を Client Component に渡す。あるいは、特定のインタラクティブなボタンや入力フィールドだけを小さなコンポーネントとして切り出し、それを Server Component 内で呼び出す構成をとります。

3. Server Actionsと「状態の同期」の罠

次に取り上げたいのが、Server Actionsを用いた際のデータ更新における挙動です。
Next.js 13以降、フォーム送信やボタンクリックによるアクションを `useActionState`(旧 `useFormState`)や `useTransition` と組み合わせて処理する際、非常に分かりにくい「状態の不整合」に遭遇することがあります。

キャッシュと再検証(Revalidation)

Server Actionsを実行した際、単に関数を叩いただけでデータが即座に変わるわけではありません。Next.jsのキャッシュシステム(Data Cache, Full Route Cache)が介在しているため、「アクションは成功したが、画面上の表示が変わらない」という現象が発生します。

これを解決するための仕組みが `revalidatePath` と `revalidateTag` です。

  • revalidatePath('/path'): 指定したパスのキャッシュを破棄し、再レンダリングを促す。
  • revalidateTag('tag_name'): 特定のタグが付与されたデータ(多くの場合、特定のAPIレスポンス)のキャッシュを破棄する。
ここでハマるポイントは、「どの範囲まで再検証すべきか」を正確に判断することです。広すぎるパスを指定すると無関係なページまで再レンダリングが発生し、範囲が狭すぎると他のコンポーネントに更新が反映されないというジレンマが生じます。

4. 実践的な実装パターン:複雑なフィルタリング機能の実装例

ここでは、Hydrationエラーを回避しつつ、Server Actionsとクライアントサイドのリアクティブな操作を共存させるための具体的なコード構造を紹介します。

例えば、「商品のリストを表示し、ユーザーがボタンを押すとサーバー側で在庫状況を更新(または再取得)する」という機能を実装する場合です。

// components/ProductCard.tsx (Client Component)
"use client";

import { useActionState } from 'react';
import { updateStockAction } from '@/app/actions';

export default function ProductCard({ product }: { product: any }) {
  // useActionState を使うことで、送信中の状態(pending)を管理しつつ
  // サーバーアクションの結果を安全に受け取ることができます。
  const [state, formAction, isPending] = useActionState(updateStockAction, null);

  return (
    <div className="border p-4 rounded shadow">
      <h3 className="text-lg font-bold">{product.name}</h3>
      <p>在庫状況: {product.stock}</p>
      
      <form action={formAction}>
        <input type="hidden" name="productId" value={product.id} />
        <button 
          type="submit" 
          disabled={isPending}
          className={`${isPending ? "bg-gray-400" : "bg-blue-500"} text-white px-4 py-2 rounded`}
        >
          {isPending ? '更新中...' : '在庫を確保する'}
        </button>
      </form>

      {/* サーバーからのエラーメッセージなどを動的に表示 */}
      {state?.error && <p className="text-red-500 mt-2 text-sm">{state.error}</p>}
    </div>
  );
}

このコードのポイントは以下の通りです。
1. 粒度の細かいコンポーネント分割: カード全体を `use client` にするのではなく、フォーム操作が必要な部分だけを抽出して設計します。
2. Server Actionの直接利用: APIエンドポイントを個別に定義せず、アクションを直接呼び出すことで、型安全性を保ちつつ状態更新を実現します。
3. Progressive Enhancement: `useActionState` を活用することで、JavaScriptが読み込まれる前であってもフォーム送信が可能になるという、Next.jsの強力な特性を活かすことができます。

5. パフォーマンスとメンテナンス性のための比較

最後に、従来の手法(Pages Router + API Routes)と現在のモダンな手法(App_Router + Server Actions)を比較しましょう。

機能Pages Router (Legacy)App Router (Modern)メリット / デメリット
データ取得`getStaticProps` / `getServerSideProps`Server Components 内での直接 fetchアプリケーション構造の平坦化と型安全性の向上
状態更新API Route + `fetch`/`axios` + `useState`Server Actions + `useActionState`状態管理の複雑さの低減。ただし、キャッシュ制御の学習コストが高い
レンダリング全体または部分的なクライアントサイドコンポーネント単位でのサーバー/クライアント切り替えパフォーマンス最適化が容易になる一方、Hydrationへの注意が必要
開発体験ページベースのルーティングファイルシステムベースのルートとレイアウトより高度なレイアウト共有が可能。ただし学習曲線は急峻

まとめ:技術的な「沼」から抜け出すためのマインドセット

Next.jsのApp Routerは、単に新しい機能を追加しただけではありません。それは「Webの仕組み(HTTP, HTML, JS)をどう効率的に組み合わせるか」という設計思想の転換です。

Hydrationエラーに悩まされたら、まずは「なぜこのコンポーネントがクライアントで動く必要があるのか?」と自分に問いかけてみてください。

  • サーバー側で処理できるなら、可能な限りServer Componentのままにする。
  • クライアントでのインタラクションが必要な場合のみ、その最小単位をClient Componentとして切り出す。
  • グローバルな状態管理(Context APIなど)を多用するのではなく、URLパラメータやクエリ文字列、あるいはServer Actionによる直接的なデータ更新を活用する。
この「設計の意図」を意識することで、技術的なハマりポイントの多くは自然と回避できるはずです。モダンなスタックを使いこなすことは、単に最新のライブラリを覚えることではなく、その背後にある思想を理解することから始まります。