Hydration mismatch terjadi ketika representasi Virtual DOM yang dihasilkan oleh server (Nitro SSR) tidak identik dengan Virtual DOM yang dibuat oleh Vue pada client saat inisialisasi awal. Vue membandingkan struktur DOM nyata hasil SSR dengan tree vnode client; jika ada perbedaan tag, teks, atau atribut, Vue membatalkan rekonsiliasi mulus (hydration bail-out). Akibatnya, browser membuang DOM lama, me-render ulang node secara sinkron, memicu Cumulative Layout Shift (CLS), dan berpotensi merusak event listener.

Penyebab Utama: Rendering Responsif Berbasis Browser API

Penyebab paling umum pada Nuxt 3 adalah evaluasi logika responsif menggunakan variabel browser seperti window.innerWidth, window.matchMedia, atau library berbasis event resize client-side di dalam conditional rendering (v-if).

Server engine (Nitro) berjalan di Node.js atau runtime worker tanpa konteks visual layar browser. Server merender fallback standar (misalnya asumsi desktop atau false), sedangkan client langsung membaca ukuran viewport riil perangkat dan mencoba merender cabang komponen yang berbeda.

Contoh Kode Pemicu Bug

Komponen di bawah ini secara instan memicu hydration mismatch saat diakses dari perangkat mobile:

<script setup lang="ts">
import { ref, onMounted } from 'vue'

// State diinisialisasi false untuk SSR
const isMobile = ref(false)

// Bug: window hanya ada di client
if (import.meta.client) {
  isMobile.value = window.innerWidth < 768
}
</script>

<template>
  <header>
    <nav v-if="isMobile" class="mobile-nav">
      <button>Menu Hamburger</button>
    </nav>
    <nav v-else class="desktop-nav">
      <ul>
        <li>Beranda</li>
        <li>Produk</li>
      </ul>
    </nav>
  </header>
</template>

Analisis Error Diff pada Console

Ketika aplikasi di-load di layar dengan lebar 375px, konsol browser akan menampilkan peringatan:

[Vue warn]: Hydration node mismatch:
- Server rendered: <nav class="desktop-nav">...</nav>
- Client vnode: <nav class="mobile-nav">...</nav>
  at <Header>
  at <App>

Server mengirim markup <nav class="desktop-nav"> ke dalam stream HTML. Ketika runtime Vue di client mengeksekusi setup, nilai isMobile langsung bernilai true sebelum proses mount/hydration selesai. Vue mendapati node DOM aktual adalah .desktop-nav sementara tree vnode meminta .mobile-nav. Vue terpaksa menghapus node DOM server dan menyisipkan node baru.

Solusi 1: Native CSS Media Query (Tanpa JS Branching)

Pendekatan paling bersih dan efisien secara performa adalah menghapus conditional rendering (v-if) dan membiarkan rendering engine browser menangani visibilitas menggunakan CSS.

<template>
  <header>
    <nav class="mobile-nav">
      <button>Menu Hamburger</button>
    </nav>
    <nav class="desktop-nav">
      <ul>
        <li>Beranda</li>
        <li>Produk</li>
      </ul>
    </nav>
  </header>
</template>

<style scoped>
.mobile-nav {
  display: block;
}
.desktop-nav {
  display: none;
}

@media (min-width: 768px) {
  .mobile-nav {
    display: none;
  }
  .desktop-nav {
    display: flex;
  }
}
</style>

Kelebihan: Zero hydration mismatch. DOM server dan client 100% konsisten. Tidak ada layout shift.

Kekurangan: Kedua cabang elemen tetap masuk ke dalam DOM. Jika komponen desktop berukuran sangat besar (misalnya tabel data kompleks), pendekatan ini menambah ukuran HTML awal.

Solusi 2: Komponen <ClientOnly> dengan Presisi Fallback

Jika komponen mobile dan desktop memuat pustaka pihak ketiga yang berat (misalnya peta interaktif atau library chart), gunakan komponen bawaan Nuxt: <ClientOnly>. Kunci stabilitasnya terletak pada penyediaan slot #fallback dengan dimensi geometris yang setara.

<template>
  <div class="nav-container">
    <ClientOnly>
      <ResponsiveNavigation />
      <template #fallback>
        <!-- Placeholder struktural dengan tinggi yang sama untuk mencegah CLS -->
        <div class="nav-skeleton h-16 w-full bg-neutral-100" />
      </template>
    </ClientOnly>
  </div>
</template>

Nitro hanya akan merender markup di dalam slot #fallback ke HTML. Di client, Vue menahan mounting komponen ResponsiveNavigation sampai proses hidrasi skeleton selesai, kemudian menukarnya tanpa memicu konflik diff DOM.

Solusi 3: Device Detection Berbasis Request Header via Nitro

Jika tampilan harus berbeda secara struktural dari server (SSR-rendered HTML sudah harus spesifik untuk mobile atau desktop tanpa mengirimkan dua markup), lakukan deteksi perangkat pada layer server via HTTP headers (Sec-CH-UA-Mobile atau User-Agent).

Buat composable untuk mengekstrak tipe perangkat secara konsisten:

// composables/useDeviceType.ts
export const useDeviceType = () => {
  const headers = useRequestHeaders(['user-agent', 'sec-ch-ua-mobile'])
  
  return useState<'mobile' | 'desktop'>('device-type', () => {
    if (import.meta.server) {
      const isMobileHeader = headers['sec-ch-ua-mobile'] === '?1'
      const ua = headers['user-agent'] || ''
      const isUAMobile = /mobile|android|iphone|ipad/i.test(ua)
      
      return isMobileHeader || isUAMobile ? 'mobile' : 'desktop'
    }
    // Di client, gunakan state yang sudah di-serialize oleh SSR
    return 'desktop'
  })
}

Gunakan state tersebut pada template:

<script setup lang="ts">
const deviceType = useDeviceType()
</script>

<template>
  <header>
    <MobileNavigation v-if="deviceType === 'mobile'" />
    <DesktopNavigation v-else />
  </header>
</template>

Karena useState mentransfer nilai state dari server ke payload client (dehydration/hydration state), client akan membaca nilai yang sama persis dengan yang diputuskan server pada render pertama.

Verifikasi Menggunakan Nuxt DevTools

Lakukan verifikasi perbaikan menggunakan alur kerja berikut:

  1. Jalankan project dalam mode development (npx nuxi dev).
  2. Buka aplikasi di browser dan aktifkan Nuxt DevTools (tekan Shift + Alt + D).
  3. Pilih tab Components, lalu periksa tree hirarki. Pastikan tag peringatan oranye Hydration Mismatch tidak lagi muncul di samping nama komponen.
  4. Buka tab Console pada Browser DevTools. Muat ulang halaman (hard reload Ctrl + F5) dengan simulasi perangkat mobile diaktifkan pada Device Mode. Pastikan tidak ada pesan [Vue warn]: Hydration node mismatch.