Vue.js

NuxtのHydration mismatchを直す|乱数・日付・画面幅・データ取得の切り分け

NuxtのHydration mismatchを、警告が出るページと修正版で比較。useState・useFetch・onMounted・ClientOnlyの役割を分け、日付や画面幅、DOMの不一致を直します。

この記事の目次
  1. サーバー・ブラウザーの初回・マウント後を分ける
  2. 警告が出る最小ページを動かす
  3. 同じ初期値を渡し、端末依存の表示を後へ移す
  4. 乱数はuseState、取得したデータはuseFetchで引き継ぐ
  5. 日付は元の時刻とタイムゾーンを両方そろえる
  6. 見た目の幅分岐はCSS、端末の値はonMountedへ
  7. DOMの入れ子と、SSRでの例外を別に調べる
  8. 再読み込みと環境差で、修正を確かめる

画面は表示されるのに、再読み込みするとConsoleにHydration mismatchが出る。Nuxtでは、サーバーが返したHTMLと、ブラウザーが最初に描こうとした内容が一致するかを確認するところから切り分けます。

同じコンポーネントでも、乱数や現在時刻を別々に作る、ブラウザーだけで条件を変える、APIをもう一度取得する、といった処理で初期表示が変わります。Vueが表示を修復して画面が動く場合もありますが、原因を残すと表示のちらつきや余分な描画につながります。

以下はNuxt 4.5.2・Vue 3.5.42・Node.js 24.13.0で確認した再現例です。Nuxt 2のフィルターや設定形式は扱いません。SSRを使うかどうかの選択ではなく、SSRを使うページで警告を直す手順に絞ります。

サーバー・ブラウザーの初回・マウント後を分ける

Hydrationは、サーバーが作ったHTMLへ、ブラウザー側のVueが状態やイベント処理を結び付ける段階です。比較されるのは、マウント後に操作した最終画面ではなく、受け取ったDOMとブラウザー側の初期描画です。

タイミング 何をそろえるか
サーバーでHTMLを作る ページの初期値と構造を決める
ブラウザーで初期描画する その値と構造を引き継いでHydrationする
onMountedの後 端末情報や操作に応じた表示へ更新できる

たとえば「幅を確認中」をサーバーとブラウザーの初回で共通に表示し、onMountedで「幅:390px」へ変えることは可能です。最初からサーバーでは「デスクトップ用」、ブラウザーでは「モバイル用」と描くのとは、変える時点が違います。

スポンサーリンク

警告が出る最小ページを動かす

空のフォルダーに次のpackage.jsonとnuxt.config.tsを保存します。さらに、記載したパスどおりにapp/pagesとserver/apiを作ります。このサンプルのAPIは呼ぶたびに値を変える検証用で、実データは使いません。

{
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "nuxt dev --host 127.0.0.1 --port 5191",
    "build": "nuxt build"
  },
  "dependencies": {
    "nuxt": "4.5.2"
  }
}
export default defineNuxtConfig({
  compatibilityDate: '2026-09-12',
  devtools: { enabled: false },
  telemetry: false,
})

server/api/sample.get.ts:

export default defineEventHandler(() => {
  // 呼ぶたび値が変わる検証専用API。実データは扱わない。
  return { value: crypto.randomUUID() }
})

app/pages/bad.vue:

<script setup>
const random = Math.random()
const date = new Intl.DateTimeFormat('ja-JP', { dateStyle: 'short' })
  .format(new Date('2026-09-11T23:30:00Z'))
const mobile = import.meta.client && window.innerWidth < 768
const data = await $fetch('/api/sample')
</script>

<template>
  <main>
    <h1>初期表示が一致しない例</h1>
    <p id="random">乱数:{{ random }}</p>
    <p id="date">日付:{{ date }}</p>
    <p v-if="mobile" id="layout">モバイル用</p>
    <p v-else id="layout">デスクトップ用</p>
    <p id="data">取得値:{{ data.value }}</p>
  </main>
</template>

依存をnpm installで入れ、macOS/LinuxならTZ=UTC npm run devで起動します。これは日付のずれを再現するために、サーバーのタイムゾーンをUTCにする指定です。ブラウザーは日本時間の設定でhttp://127.0.0.1:5191/badを直接開き、ConsoleとNetworkを確認してください。

今回の検証では、幅390pxで4か所、1440pxで3か所のHydration text content mismatchが出ました。幅1440pxでは幅の条件がサーバー側と同じになるため、その箇所の警告は出ません。

箇所 一致しない理由
乱数 サーバーとブラウザーでMath.random()を別々に実行する
日付 同じ時刻でも、UTCでは9月11日、日本時間では9月12日になる
画面幅 サーバーではmobileがfalse、幅390pxのブラウザーではtrueになる
取得値 setup内の$fetchがクライアントでも実行され、別の値が返る

Consoleのrendered on serverとexpected on clientを見て、まず何の文字列が違うかを絞ります。日付の警告が出ない環境でも、サーバーとブラウザーのタイムゾーンがたまたま同じなら、その条件では再現しないことがあります。

同じ初期値を渡し、端末依存の表示を後へ移す

app/pages/fixed.vueとapp/components/BrowserInfo.vueを追加し、/fixedへ直接アクセスします。修正は四つの処理で役割が異なります。

<script setup>
const random = useState('hydration-demo-random', () => Math.random())
const date = new Intl.DateTimeFormat('ja-JP', {
  dateStyle: 'short', timeZone: 'Asia/Tokyo',
}).format(new Date('2026-09-11T23:30:00Z'))
const width = ref(null)
onMounted(() => { width.value = window.innerWidth })
const { data, error } = await useFetch('/api/sample')
</script>

<template>
  <main>
    <h1>初期表示をそろえた例</h1>
    <p id="random">乱数:{{ random }}</p>
    <p id="date">日付:{{ date }}</p>
    <p class="mobile">モバイル用</p>
    <p class="desktop">デスクトップ用</p>
    <p id="width">{{ width === null ? '幅を確認中' : `幅:${width}px` }}</p>
    <p v-if="error">データを取得できませんでした</p>
    <p v-else-if="data" id="data">取得値:{{ data.value }}</p>
    <ClientOnly>
      <BrowserInfo />
      <template #fallback><p>ブラウザー情報を準備中</p></template>
    </ClientOnly>
  </main>
</template>

<style scoped>
main { max-width: 640px; margin: 24px auto; padding: 0 16px; }
.mobile { display: none; }
@media (max-width: 767px) {
  .desktop { display: none; }
  .mobile { display: block; }
}
</style>

app/components/BrowserInfo.vue:

<script setup>
// このコンポーネントはClientOnlyの内側だけで使う。
const language = navigator.language
</script>
<template><p id="browser">ブラウザーの言語:{{ language }}</p></template>

乱数はuseState、取得したデータはuseFetchで引き継ぐ

乱数はuseStateの初期化関数で作り、サーバーで決めた値をブラウザーへ渡します。キーはほかの用途と衝突しない名前にします。単にref(Math.random())へ置き換えるだけでは、両側で別の値を作る問題は残ります。

useStateの内容はブラウザーへ渡るため、秘密情報を入れません。この例のような数値や、JSONとして扱えるデータを基本にします。アプリ外のモジュール変数にユーザーごとの状態を置いて共有する方法も避けます。

APIの結果はawait useFetchで受け取ります。この例の初回Hydrationでは、サーバーで取得した結果がNuxtのpayloadを通じて再利用され、ブラウザーからの追加リクエストがなくなりました。ボタン操作などで後から取得する$fetchまで、すべて不適切という意味ではありません。

日付は元の時刻とタイムゾーンを両方そろえる

ここでは固定のISO日時を使い、両側ともAsia/Tokyoで整形しています。書式の選び方はNuxtで日付をフォーマットする記事へ譲ります。

「現在時刻」を使うなら、タイムゾーンを同じにするだけでは足りません。サーバーとブラウザーでnew Date()を呼ぶ時刻も違います。最初に表示するISO文字列をuseStateなどで渡すか、最初は共通の表示にし、端末の時刻はonMounted後に表示します。

見た目の幅分岐はCSS、端末の値はonMountedへ

モバイル用・デスクトップ用の文字列は、HTMLの構造を変えずメディアクエリで表示を切り替えます。複雑な操作部品を二重に置く場合は、重複IDや非表示側の処理が動くことも別途確認します。

幅の数値は、最初はnullにして「幅を確認中」と表示し、onMountedで読み取ります。この短い例が表示するのはマウント時点の幅です。リサイズへ追従するなら、監視処理と終了時の解除も必要になります。

ブラウザーの言語を読むBrowserInfoは、ClientOnlyの内側だけで使っています。サーバーではfallbackを返し、クライアントで部品をマウントします。ClientOnlyで囲んでも、その外側にある親のscript setupがブラウザー専用になるわけではありません。依存ライブラリのimport自体がwindowなどに触るなら、部品の境界やonMounted内の動的importまで見直します。

ページ全体をClientOnlyにすると、サーバーが返す本文もfallback中心になります。初期HTMLに残したい内容は初期値をそろえ、本当にブラウザーだけで動かす部品に限定して使います。

DOMの入れ子と、SSRでの例外を別に調べる

値が同じでも、HTMLの入れ子が不正だとブラウザーが構造を補正します。次をapp/pages/invalid-html.vueへ置くと、pの中のdivが原因で警告を再現できます。

<template><main><h1>不正な入れ子の例</h1><p><div>本文</div></p></main></template>

修正するなら、pの中へdivを入れず、たとえば<main><h1>見出し</h1><div><p>本文</p></div></main>と、意味に合う正しい入れ子へ直します。Elementsはブラウザーが補正した後のDOMなので、NetworkのHTML応答も見比べます。

一方、次のapp/pages/window-error.vueは、初期DOMの比較まで進む前にSSRが失敗する例です。

<script setup>
const width = window.innerWidth
</script>
<template><p>{{ width }}</p></template>

検証環境ではHTTP 500となり、サーバーのエラーはCannot read properties of undefined (reading 'innerWidth')でした。環境によってはwindow is not definedなどの文になります。この場合は、まずサーバー側の例外を解消します。

import.meta.client && window.innerWidth < 768のように参照をガードすればSSRでの参照は避けられますが、bad.vueのように両側の初期値が違えばHydrationの不一致は残ります。例外を避けることと、表示を一致させることを分けて確認してください。

再読み込みと環境差で、修正を確かめる

修正版はChrome 149.0.7827.55、幅390px・1440px、サーバーUTC・ブラウザーAsia/Tokyoで確認しました。両幅でHydrationの警告は0件、初回Hydration時のブラウザーからのAPIリクエストは0件でした。乱数と取得値はSSRのHTMLと一致し、日付は両側とも2026/09/12になっています。

  1. アプリ内の遷移だけでなく、対象URLを直接開き、再読み込みする。
  2. Consoleの最初の不一致から、該当する値・タグ・条件分岐を探す。
  3. HTML応答とElementsを比べる。ブラウザーのHTML補正や、拡張機能・外部スクリプトによる変更も切り分ける。
  4. 初期値を共有する処理と、マウント後へ移す処理を分けて直す。
  5. 画面幅、タイムゾーン、ログイン状態など、元の不一致が起きた条件でもう一度確認する。

Vueには意図した不一致の警告を限定的に抑える仕組みもありますが、今回の原因は値や構造を直せるものです。警告だけを隠して完了にせず、初期HTML、表示、操作が意図どおりかを確かめます。検証用のbad・invalid-html・window-errorページは、本番用アプリへ残さないでください。

仕様の確認先:NuxtのHydrationガイド、useState、useFetch、ClientOnly、VueのHydration mismatch(2026年9月12日確認)。

スポンサーリンク