Hydration mismatch karena browser API di SSR Vue/Nuxt biasanya terjadi ketika HTML yang dirender di server tidak sama dengan hasil render pertama di browser. Penyebab paling umum adalah komponen mencoba membaca window, document, localStorage, matchMedia, atau ukuran viewport saat fase render awal, padahal data tersebut hanya tersedia atau hanya akurat di sisi client.
Gejalanya sering membingungkan: teks atau layout berubah setelah mount, muncul warning hydration di console, elemen meloncat, tema gelap-terang berganti sesaat, atau state awal berbeda antara server dan client. Akar masalahnya bukan sekadar “API browser tidak tersedia di server”, melainkan render yang tidak deterministik: server dan client menghitung output awal dari sumber data yang berbeda.
Apa itu hydration mismatch di SSR Vue/Nuxt?
Pada SSR, server mengirim HTML awal ke browser agar halaman bisa tampil cepat dan tetap terbaca tanpa menunggu JavaScript selesai. Setelah itu, Vue melakukan hydration: event listener dipasang dan state client disejajarkan dengan DOM yang sudah ada.
Masalah muncul jika hasil render pertama di client tidak cocok dengan HTML dari server. Vue kemudian mendeteksi perbedaan struktur atau konten, mengeluarkan warning hydration, dan dalam beberapa kasus harus membuang DOM lama lalu merender ulang. Dampaknya bisa berupa:
- Konten berubah setelah mount, misalnya label tombol, tema, atau menu yang langsung berganti.
- Layout shift, misalnya sidebar tiba-tiba muncul karena lebar viewport baru diketahui di client.
- State awal berbeda, contohnya server menganggap user memakai tema light, client membaca
localStoragelalu mengganti ke dark. - Warning hydration di console yang sulit dilacak karena sumbernya tersembunyi dalam computed, composable, atau conditional rendering.
Root cause: render awal yang tidak deterministik
Dalam konteks SSR, render awal harus deterministik: input yang dipakai server dan client untuk menghasilkan HTML pertama harus sama. Browser API merusak asumsi ini karena:
- Tidak tersedia di server, seperti
windowdandocument. - Nilainya hanya diketahui di browser, seperti ukuran viewport atau preferensi media query.
- Bisa berbeda antar lingkungan, misalnya isi
localStorageuser tertentu.
Contoh sederhana: server merender teks Desktop karena tidak tahu ukuran layar, lalu client membaca window.innerWidth dan memutuskan seharusnya Mobile. HTML awal tidak lagi konsisten.
Kapan masalah ini biasanya muncul?
- Saat memakai
v-ifberdasarkanwindow.innerWidthataumatchMedia. - Saat menentukan tema dari
localStoragelangsung disetup()atau computed awal. - Saat mengakses
document.documentElementuntuk class tema sebelum mount. - Saat composable membaca browser API tanpa guard SSR.
- Saat nilai turunan UI, seperti jumlah kolom atau mode layout, dihitung dari viewport di render pertama.
Pola yang salah: mengakses browser API terlalu dini
Berikut contoh yang terlihat sederhana, tetapi rentan memicu hydration mismatch.
Contoh buruk: membaca viewport saat render awal
<script setup>
import { ref } from 'vue'
const isMobile = ref(window.innerWidth < 768)
</script>
<template>
<div>
<p v-if="isMobile">Menu mobile</p>
<p v-else>Menu desktop</p>
</div>
</template>Masalahnya ada dua. Pertama, window tidak ada di server. Kedua, sekalipun diberi guard agar tidak error, hasil render awal server dan client bisa tetap berbeda.
Contoh buruk: tema dari localStorage di setup
<script setup>
import { ref } from 'vue'
const theme = ref(localStorage.getItem('theme') || 'light')
</script>
<template>
<body :class="theme">
<slot />
</body>
</template>Server tidak bisa membaca localStorage. Jika server merender light tetapi client menyimpan dark, UI akan berubah setelah hydration.
Pola perbaikan yang aman untuk SSR
Tujuan utama perbaikan bukan membuat browser API “bisa dipanggil”, tetapi memastikan render awal SSR stabil dan perubahan yang hanya bisa diketahui di client terjadi secara sadar setelah mount.
1. Gunakan guard client-only
Jika logic memang hanya relevan di browser, lindungi aksesnya agar tidak ikut dieksekusi saat SSR.
<script setup>
import { ref, onMounted } from 'vue'
const width = ref(null)
onMounted(() => {
width.value = window.innerWidth
})
</script>
<template>
<p v-if="width !== null">Lebar: {{ width }}</p>
<p v-else>Memuat ukuran layar...</p>
</template>Kenapa ini bekerja? Karena server dan client sama-sama merender fallback yang konsisten terlebih dahulu. Pembacaan window baru dilakukan setelah komponen hidup di browser.
Catatan: Guard client-only mencegah error SSR, tetapi belum tentu mencegah mismatch bila markup fallback server berbeda dengan render awal client. Pastikan fallback awalnya stabil.
2. Pakai nilai fallback SSR yang stabil
Untuk data yang hanya diketahui di browser, sediakan nilai awal yang tidak bergantung pada browser API. Nilai ini harus cukup netral agar aman dirender di server.
<script setup>
import { ref, computed, onMounted } from 'vue'
const width = ref(null)
const isMobile = computed(() => width.value !== null && width.value < 768)
onMounted(() => {
width.value = window.innerWidth
})
</script>
<template>
<nav>
<button v-if="width === null">Menu</button>
<button v-else-if="isMobile">Menu mobile</button>
<div v-else>Navigasi desktop</div>
</nav>
</template>Di sini server tidak menebak-nebak apakah user mobile atau desktop. Server merender fallback netral, lalu client menyempurnakan state setelah mount.
3. Defer logic ke onMounted
Jika sebuah keputusan UI memang membutuhkan browser API, tunda perhitungannya ke onMounted(). Ini pola yang paling aman untuk akses window, document, event resize, dan media query.
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const prefersDark = ref(false)
let mediaQuery
onMounted(() => {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
prefersDark.value = mediaQuery.matches
const update = (event) => {
prefersDark.value = event.matches
}
mediaQuery.addEventListener?.('change', update)
onBeforeUnmount(() => {
mediaQuery?.removeEventListener?.('change', update)
})
})
</script>Pola ini juga menghindari akses browser API dari computed atau top-level code yang dieksekusi terlalu dini.
4. Pisahkan state sumber dan state turunan
Kesalahan umum adalah menjadikan browser API sebagai bagian langsung dari state render utama. Lebih aman jika state dibagi menjadi:
- State sumber SSR-stabil, misalnya
null,unknown, atau nilai default yang konsisten. - State turunan client, misalnya
isMobile,theme, ataucolumns, yang dihitung setelah data browser tersedia.
<script setup>
import { ref, computed, onMounted } from 'vue'
const viewportWidth = ref(null)
const columns = computed(() => {
if (viewportWidth.value === null) return 2
if (viewportWidth.value < 768) return 1
if (viewportWidth.value < 1200) return 2
return 4
})
onMounted(() => {
viewportWidth.value = window.innerWidth
})
</script>
<template>
<ProductGrid :columns="columns" />
</template>Server selalu memulai dari nilai yang diketahui, misalnya 2 kolom. Setelah mount, jumlah kolom boleh berubah. Jika perubahan ini berisiko menimbulkan layout shift besar, pertimbangkan apakah keputusan layout sebaiknya dipindahkan ke CSS responsif, bukan JavaScript.
5. Bungkus browser API dalam composable yang aman untuk SSR
Alih-alih memanggil browser API langsung di banyak komponen, buat composable yang:
- Memberi nilai awal yang aman untuk SSR.
- Hanya mengakses browser API setelah mount.
- Mengelola event listener dan cleanup dengan benar.
Contoh composable aman: ukuran viewport
import { ref, onMounted, onBeforeUnmount } from 'vue'
export function useViewport() {
const width = ref(null)
const height = ref(null)
const update = () => {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => {
update()
window.addEventListener('resize', update)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', update)
})
return { width, height }
}Pemakaian:
<script setup>
import { computed } from 'vue'
import { useViewport } from '~/composables/useViewport'
const { width } = useViewport()
const isMobile = computed(() => width.value !== null && width.value < 768)
</script>
<template>
<aside v-if="width !== null && !isMobile">Sidebar desktop</aside>
</template>Keuntungan pendekatan ini adalah semua logika SSR-safe terkonsentrasi di satu tempat sehingga lebih mudah diaudit dan diuji.
Contoh buruk vs benar yang sering terjadi
Kasus 1: localStorage untuk state awal
Buruk:
<script setup>
const collapsed = ref(localStorage.getItem('sidebar') === '1')
</script>Benar:
<script setup>
import { ref, onMounted, watch } from 'vue'
const collapsed = ref(false)
onMounted(() => {
collapsed.value = localStorage.getItem('sidebar') === '1'
})
watch(collapsed, (value) => {
localStorage.setItem('sidebar', value ? '1' : '0')
})
</script>Server selalu menganggap sidebar terbuka sebagai default stabil. Client menyesuaikan preferensi user setelah mount.
Kasus 2: conditional rendering berdasarkan media query
Buruk:
<script setup>
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
</script>
<template>
<DarkHero v-if="isDark" />
<LightHero v-else />
</template>Benar:
<script setup>
import { ref, onMounted } from 'vue'
const isDark = ref(null)
onMounted(() => {
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
})
</script>
<template>
<HeroSkeleton v-if="isDark === null" />
<DarkHero v-else-if="isDark" />
<LightHero v-else />
</template>Fallback skeleton membuat render server konsisten. Ini lebih aman daripada membiarkan server menebak hasil media query.
Kasus 3: ukuran viewport untuk layout
Buruk:
<template>
<DesktopTable v-if="window.innerWidth > 1024" />
<MobileCards v-else />
</template>Benar:
Jika perbedaannya hanya layout, lebih baik gunakan CSS responsif daripada mengganti struktur DOM besar dengan JavaScript.
<template>
<section class="results">
<div class="table-view">...</div>
<div class="card-view">...</div>
</section>
</template>
<style scoped>
.table-view { display: block; }
.card-view { display: none; }
@media (max-width: 1024px) {
.table-view { display: none; }
.card-view { display: block; }
}
</style>Kenapa lebih baik? Karena server tidak perlu mengetahui ukuran viewport untuk menghasilkan HTML yang valid. CSS di browser yang memutuskan presentasi akhir.
Kapan memakai client-only, kapan cukup fallback?
Pilih fallback SSR stabil jika:
- Konten tetap penting untuk SEO atau aksesibilitas.
- Anda masih ingin HTML awal tampil cepat dari server.
- Perbedaan client hanya berupa penyempurnaan kecil setelah mount.
Pilih render client-only jika:
- Komponen memang sepenuhnya bergantung pada browser API.
- Kontennya tidak penting untuk SEO.
- Biaya membuat fallback SSR yang benar terlalu besar atau terlalu rawan mismatch.
Trade-off-nya jelas: client-only mengurangi risiko hydration mismatch, tetapi HTML awal untuk komponen itu tidak hadir penuh dari server. Untuk widget interaktif non-kritis, ini sering masuk akal. Untuk konten utama halaman, biasanya lebih baik mempertahankan SSR dengan fallback stabil.
Checklist debugging hydration mismatch di Vue/Nuxt
- Cari penggunaan browser API di
setup(), computed, watcher awal, top-level composable, dan plugin. - Periksa conditional rendering seperti
v-ifatauv-showyang bergantung pada viewport, tema, atau storage. - Bandingkan state awal server dan client. Tanyakan: data apa yang tersedia saat SSR, dan data apa yang baru ada setelah mount?
- Uji dengan JavaScript lambat atau throttling agar perubahan setelah hydration terlihat jelas.
- Lihat warning console untuk petunjuk node mana yang mismatch.
- Audit composable bersama. Sering kali akar masalah bukan di komponen, melainkan di utilitas yang membaca
windowsecara implisit. - Pastikan fallback stabil. Jika server merender skeleton, client juga harus memulai dari skeleton sebelum state browser tersedia.
- Pertimbangkan CSS daripada JS untuk urusan responsif dan presentasi.
Kesalahan umum yang sering terlewat
- Guard hanya mencegah error, bukan mismatch. Misalnya
if (typeof window !== 'undefined')memang aman dari crash, tetapi jika client langsung merender konten berbeda dari server, mismatch tetap mungkin terjadi. - Computed yang tampak aman bisa tetap bermasalah jika sumbernya berasal dari browser API yang belum stabil saat SSR.
- Nilai default yang terlalu agresif dapat menyebabkan layout shift besar. Default stabil belum tentu default terbaik untuk UX.
- Mengganti struktur DOM besar berdasarkan viewport biasanya lebih rawan dibanding menyesuaikan tampilan dengan CSS.
Trade-off SEO, UX, dan performa
SEO
SSR membantu crawler dan pengguna mendapatkan HTML awal yang lengkap. Jika terlalu banyak komponen dipindahkan menjadi client-only, sebagian konten mungkin tidak hadir di HTML server. Untuk elemen dekoratif atau widget personalisasi, ini biasanya tidak masalah. Untuk heading, konten utama, navigasi penting, dan data inti halaman, usahakan tetap tersedia lewat SSR.
UX
Fallback SSR yang stabil mengurangi warning hydration, tetapi bisa menimbulkan perubahan visual setelah mount jika state browser ternyata berbeda. Solusinya bukan selalu memaksa deteksi lebih awal, melainkan memilih fallback yang netral, kecil, dan tidak mengganggu. Untuk layout, CSS responsif sering memberi UX yang lebih halus daripada mengganti komponen besar setelah mount.
Performa
SSR memberi tampilan awal lebih cepat, tetapi mismatch bisa memicu render ulang yang mengurangi manfaatnya. Menunda logic ke onMounted() menambah satu fase update di client, namun sering lebih murah daripada memaksa hydration pada DOM yang berbeda. Composable yang aman untuk SSR juga membantu menghindari akses browser API berulang dan pengelolaan event listener yang berantakan.
Rekomendasi praktis
- Jangan membaca
window,document,localStorage,matchMedia, atau ukuran viewport di render awal SSR. - Mulai dari nilai fallback yang stabil dan netral.
- Pindahkan pembacaan browser API ke
onMounted(). - Pisahkan state sumber yang aman untuk SSR dari state turunan yang bergantung pada browser.
- Buat composable SSR-safe agar pola akses browser API konsisten di seluruh aplikasi.
- Untuk responsif, prioritaskan CSS jika masalahnya hanya presentasi, bukan data.
Penutup
Mencegah hydration mismatch karena browser API di SSR Vue/Nuxt pada dasarnya adalah menjaga agar server dan client memiliki titik awal render yang sama. Begitu komponen bergantung pada data yang hanya ada di browser, Anda perlu memilih: sediakan fallback SSR yang stabil, tunda logic ke onMounted(), atau jadikan komponen client-only jika memang sepenuhnya bergantung pada browser.
Jika Anda melihat konten berubah setelah mount, warning hydration, atau layout yang meloncat, jangan hanya mencari cara agar window “tidak error”. Telusuri apakah render awal Anda sudah deterministik. Biasanya, di situlah sumber masalah sebenarnya.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!