Hydration mismatch adalah salah satu masalah yang paling sering membingungkan saat membangun aplikasi server-side rendering (SSR) dengan Nuxt 3. Gejalanya biasanya muncul sebagai peringatan di console browser seperti Hydration node mismatch, Text content does not match server-rendered HTML, atau DOM tiba-tiba dirender ulang di sisi client. Walaupun aplikasi kadang terlihat tetap berjalan, mismatch ini tidak boleh dianggap sepele karena bisa menyebabkan UI berkedip, event tidak menempel sebagaimana mestinya, performa menurun, dan perilaku yang sulit direproduksi.

Inti masalahnya sederhana: HTML yang dihasilkan di server harus sama dengan HTML awal yang diharapkan oleh Vue saat proses hydration di browser. Jika ada perbedaan nilai, struktur node, atau urutan elemen, Vue akan mendeteksi ketidaksesuaian. Di Nuxt 3, kasus ini sering berasal dari penggunaan nilai yang tidak deterministik seperti Math.random() dan Date.now(), akses API browser seperti window saat SSR, data async yang berbeda antara server dan client, atau format tampilan yang bergantung pada locale dan timezone.

Artikel ini fokus pada pemecahan masalah secara praktis: memahami penyebab, mengenali pola error, melakukan debug secara sistematis, dan menerapkan pola coding yang aman agar output server dan client tetap konsisten.

Apa itu hydration mismatch di Nuxt 3?

Pada aplikasi SSR, Nuxt merender komponen di server menjadi HTML. HTML ini dikirim ke browser agar halaman bisa tampil cepat. Setelah itu, Vue di browser melakukan hydration, yaitu menghubungkan HTML yang sudah ada dengan state, event listener, dan logika reaktif di sisi client.

Masalah muncul bila hasil render awal di browser berbeda dari HTML yang dikirim server. Misalnya, server menghasilkan teks Harga promo: 42, tetapi browser saat menjalankan komponen yang sama menghasilkan Harga promo: 87. Dari sudut pandang Vue, markup yang harusnya identik ternyata berbeda. Inilah yang memicu hydration mismatch.

Catatan penting: mismatch tidak selalu berarti aplikasi langsung rusak total, tetapi itu menandakan ada inkonsistensi render. Jika dibiarkan, bug lanjutan akan lebih sulit didiagnosis.

Penyebab umum hydration mismatch

1. Nilai tidak deterministik seperti Math.random() dan Date.now()

Kesalahan paling umum adalah memanggil fungsi yang menghasilkan nilai berbeda setiap render langsung di template atau saat inisialisasi state yang ikut dirender.

<template>
  <div>Token: {{ Math.random() }}</div>
</template>

Server dan client hampir pasti menghasilkan angka berbeda. Akibatnya, konten teks tidak cocok saat hydration.

Masalah yang sama juga terjadi pada Date.now() atau new Date() bila hasilnya langsung ditampilkan:

<template>
  <p>Rendered at: {{ Date.now() }}</p>
</template>

Solusi: jika nilai memang harus stabil antara server dan client, hasilkan sekali di server lalu kirim sebagai data. Jika nilai hanya dibutuhkan di browser, hitung setelah komponen dimount.

<script setup lang="ts">
const generatedAt = useState('generated-at', () => Date.now())
</script>

<template>
  <p>Rendered at: {{ generatedAt }}</p>
</template>

Dengan useState di Nuxt, nilai dapat dibagikan secara konsisten antara SSR dan client selama siklus render awal.

2. Akses window, document, atau localStorage saat SSR

Pada server, objek browser seperti window, document, dan localStorage tidak tersedia. Lebih buruk lagi, kadang kode tidak langsung error karena dibungkus kondisi tertentu, tetapi hasil render tetap berbeda.

<script setup lang="ts">
const width = window.innerWidth
</script>

<template>
  <div>Lebar layar: {{ width }}</div>
</template>

Kode ini gagal di SSR. Bahkan jika diberi guard yang tidak tepat, server bisa merender nilai default tertentu sementara client merender nilai sebenarnya, menyebabkan mismatch.

Solusi: akses API browser hanya di client, misalnya dalam onMounted, atau gunakan process.client / import.meta.client secara hati-hati.

<script setup lang="ts">
const width = ref<number | null>(null)

onMounted(() => {
  width.value = window.innerWidth
})
</script>

<template>
  <div v-if="width !== null">Lebar layar: {{ width }}</div>
  <div v-else>Memuat ukuran layar...</div>
</template>

Pendekatan ini aman karena server dan client sama-sama merender placeholder yang konsisten sebelum komponen dimount.

3. Data async yang tidak sinkron antara server dan client

Nuxt 3 menyediakan useAsyncData dan useFetch agar data yang diambil saat SSR dapat dipakai kembali di client. Masalah muncul ketika data diambil dengan cara yang berbeda antara server dan client, atau request menghasilkan respons yang tidak konsisten.

Contoh masalah:

  • Memanggil API langsung dengan $fetch di dalam onMounted untuk data yang juga dipakai saat SSR.
  • Endpoint mengembalikan data berbeda karena cookie, header, geolokasi, atau waktu request.
  • Transformasi data dilakukan berbeda antara server dan client.

Solusi: gunakan useAsyncData atau useFetch untuk data yang memengaruhi HTML awal, dan pastikan key serta sumber datanya konsisten.

<script setup lang="ts">
const { data: products, error } = await useAsyncData('products', () =>
  $fetch('/api/products')
)
</script>

<template>
  <ul v-if="products">
    <li v-for="product in products" :key="product.id">
      {{ product.name }}
    </li>
  </ul>
  <p v-else-if="error">Gagal memuat data.</p>
  <p v-else>Memuat...</p>
</template>

Jika data memang hanya relevan di browser, jangan ikutkan ke markup SSR utama atau bungkus dengan komponen yang hanya dirender di client.

4. Perbedaan locale dan timezone

Formatting tanggal, angka, dan mata uang adalah sumber mismatch yang sering tidak disadari. Server bisa berjalan pada timezone UTC, sementara browser pengguna berada di Asia/Jakarta. Hasil toLocaleString() atau Intl.DateTimeFormat bisa berbeda meskipun input datanya sama.

<template>
  <p>{{ new Date(createdAt).toLocaleString() }}</p>
</template>

Server dan client dapat menghasilkan string yang berbeda, misalnya format tanggal, nama bulan, atau jam lokal.

Solusi: tentukan locale dan timezone secara eksplisit, atau format data di satu sisi saja lalu kirim hasil string final yang stabil.

<script setup lang="ts">
const props = defineProps<{ createdAt: string }>()

const formattedDate = computed(() =>
  new Intl.DateTimeFormat('id-ID', {
    dateStyle: 'medium',
    timeStyle: 'short',
    timeZone: 'Asia/Jakarta'
  }).format(new Date(props.createdAt))
)
</script>

<template>
  <p>{{ formattedDate }}</p>
</template>

Bila environment server tidak sepenuhnya konsisten terhadap locale tertentu, pertimbangkan memformat tanggal di API/backend lalu mengirim string final yang sudah siap tampil.

5. Percabangan render yang bergantung pada kondisi client

Pola seperti ini juga berbahaya:

<template>
  <SidebarMobile v-if="window.innerWidth < 768" />
  <SidebarDesktop v-else />
</template>

Server tidak mengetahui ukuran viewport browser. Akibatnya, ia hanya bisa merender salah satu varian berdasarkan fallback tertentu, sementara client bisa memilih varian lain.

Solusi: gunakan CSS responsif jika hanya perbedaan tampilan, atau render placeholder yang sama lalu tentukan varian setelah mounted. Jika memang komponen benar-benar bergantung pada API browser, pertimbangkan <ClientOnly>.

Langkah debugging yang sistematis

1. Baca pesan warning dengan teliti

Console browser biasanya memberi petunjuk apakah mismatch terjadi pada node, teks, atau atribut. Fokus dulu pada komponen yang disebutkan dalam stack trace. Jangan langsung menebak-nebak seluruh aplikasi.

2. Identifikasi data apa yang dirender ke HTML awal

Tanyakan: nilai apa saja yang tampil sebelum interaksi pengguna? Semua nilai itu harus konsisten antara SSR dan client. Audit template dan computed untuk mencari sumber data yang tidak deterministik.

3. Cari penggunaan API browser di jalur render

Periksa apakah ada window, document, navigator, localStorage, atau library pihak ketiga yang menyentuh DOM saat setup komponen. Kode seperti itu harus dipindahkan ke onMounted atau diisolasi ke komponen client-only.

4. Bandingkan output server dan client

Cara praktisnya adalah melihat View Source atau HTML hasil SSR, lalu membandingkannya dengan DOM setelah halaman dimuat. Fokus pada bagian yang mismatch. Jika teks di source sudah berbeda dengan yang di-render ulang oleh Vue, penyebabnya hampir pasti nilai awal yang tidak konsisten.

5. Sederhanakan komponen sampai mismatch hilang

Komentari bagian-bagian template atau state satu per satu. Ini teknik yang sangat efektif. Jika mismatch hilang setelah satu blok dihapus, Anda sudah menemukan kandidat utama.

6. Periksa data async dan cache

Pastikan key useAsyncData tidak bertabrakan antar komponen dengan sumber data yang berbeda. Periksa juga apakah endpoint mengembalikan data yang stabil pada request SSR dan request client awal.

Contoh kasus nyata dan perbaikannya

Kasus 1: Badge promo acak

<template>
  <span class="badge">Promo {{ Math.floor(Math.random() * 100) }}%</span>
</template>

Masalah: server dan client menghasilkan angka promo berbeda.

Perbaikan:

<script setup lang="ts">
const promo = useState('promo-badge', () => Math.floor(Math.random() * 100))
</script>

<template>
  <span class="badge">Promo {{ promo }}%</span>
</template>

Kasus 2: Menampilkan tema dari localStorage

<script setup lang="ts">
const theme = ref(localStorage.getItem('theme') || 'light')
</script>

Masalah: SSR tidak punya localStorage, dan nilai tema awal dapat berbeda dari hasil client.

Perbaikan:

<script setup lang="ts">
const theme = ref('light')

onMounted(() => {
  theme.value = localStorage.getItem('theme') || 'light'
})
</script>

Jika tema memengaruhi struktur HTML besar, lebih baik sinkronkan lewat cookie yang bisa dibaca saat SSR agar server dan client punya nilai awal yang sama.

Kasus 3: Widget pihak ketiga yang bergantung pada browser

Beberapa widget analitik, peta, editor rich text, atau charting library menyentuh DOM saat inisialisasi. Jika dipaksa ikut SSR, mismatch atau error runtime bisa terjadi.

Solusi: gunakan <ClientOnly> bila komponen memang tidak perlu ikut SSR.

<template>
  <ClientOnly>
    <ChartWidget :data="chartData" />
    <template #fallback>
      <div>Memuat grafik...</div>
    </template>
  </ClientOnly>
</template>

Trade-off: komponen di dalam ClientOnly tidak ikut dirender di server. Ini aman untuk kompatibilitas, tetapi mengurangi manfaat SSR pada bagian tersebut, termasuk SEO dan performa tampilan awal jika isinya penting.

Pola coding aman untuk mencegah mismatch

  • Jangan render nilai acak atau berbasis waktu langsung di template. Simpan nilai deterministik di state SSR-aware seperti useState.
  • Gunakan useAsyncData atau useFetch untuk data yang memengaruhi markup awal. Hindari pola fetch ganda yang menghasilkan isi HTML berbeda.
  • Pisahkan logika browser-only ke onMounted. Ini berlaku untuk DOM API, ukuran viewport, storage, dan integrasi library tertentu.
  • Tentukan locale dan timezone secara eksplisit. Jangan bergantung pada default environment bila output string harus identik.
  • Gunakan CSS untuk responsivitas bila memungkinkan. Jangan memilih struktur markup berdasarkan ukuran layar saat SSR jika sebenarnya cukup diatur oleh media query.
  • Gunakan ClientOnly sebagai alat isolasi, bukan solusi default untuk semua hal. Pakai bila memang komponen tidak masuk akal untuk SSR.

Kesalahan umum yang sering terjadi

  • Menganggap warning hydration bisa diabaikan karena halaman “terlihat normal”.
  • Memakai Math.random() untuk key elemen pada v-for.
  • Memformat tanggal dengan toLocaleString() tanpa locale dan timezone eksplisit.
  • Membaca cookie di client tetapi tidak menyediakannya saat SSR, atau sebaliknya.
  • Menggunakan library yang tidak SSR-safe tanpa pembungkus client-only.

Penutup

Hydration mismatch di Nuxt 3 pada dasarnya adalah masalah konsistensi output: server dan client harus sepakat terhadap HTML awal. Begitu prinsip ini dipahami, sebagian besar kasus menjadi lebih mudah dipecahkan. Audit nilai yang dirender, hilangkan sumber ketidakpastian seperti angka acak dan waktu berjalan, batasi akses API browser ke fase client, sinkronkan pengambilan data async, dan waspadai perbedaan locale atau timezone.

Jika perlu, gunakan ClientOnly untuk komponen yang memang hanya relevan di browser. Namun dalam banyak kasus, solusi terbaik bukan mematikan SSR untuk komponen, melainkan memastikan input render awal tetap deterministik. Dengan pendekatan debug yang sistematis dan pola coding yang aman, Anda bisa menjaga aplikasi Nuxt 3 tetap stabil, efisien, dan bebas dari mismatch yang sulit dilacak.