SSR aman berarti satu hal sederhana: HTML yang dikirim server harus cocok dengan render awal di klien. Saat komponen klien menjalankan efek samping tak terkendali—misalnya membaca window, localStorage, waktu lokal, cookie, atau memuat script pihak ketiga saat render—hasil markup dapat berubah sebelum proses hydration selesai. Di situlah hydration mismatch muncul.
Masalah ini mirip dengan analogi AI agent yang bertindak di luar guardrail: niat awal sistem benar, tetapi ada aksi samping yang berjalan di waktu atau konteks yang salah. Pada UI SSR, guardrail-nya adalah aturan bahwa render awal harus deterministik dan konsisten antara server dan browser. Jika komponen “bertindak sendiri” berdasarkan kondisi klien saat render, hasilnya bisa menyimpang dari HTML server dan memicu warning, UI berkedip, event handler gagal terpasang dengan benar, atau state awal menjadi tidak valid.
Apa itu hydration mismatch dan bagaimana gejalanya?
Pada SSR, server mengirim HTML awal agar halaman cepat tampil dan ramah SEO. Setelah itu, JavaScript di browser melakukan hydration, yaitu menghubungkan event handler dan state ke markup yang sudah ada. Proses ini mengasumsikan bahwa struktur dan isi HTML awal dari server sama dengan hasil render awal di klien.
Jika tidak sama, framework biasanya memberi warning seperti:
- Teks berbeda antara server dan client
- Jumlah node atau atribut tidak cocok
- Komponen dirender ulang penuh di klien
- UI berkedip karena markup server diganti total
Gejala praktis yang sering terlihat:
- Tanggal atau angka berubah sesaat setelah halaman dimuat
- Tombol muncul/hilang setelah hydration
- Komponen pihak ketiga merusak struktur DOM
- Data tampil dua kali karena fetch ganda
- Preferensi tema atau bahasa menyebabkan class/markup berbeda saat boot
Root cause: render awal tidak deterministik
Akar masalah paling umum adalah render function menghasilkan output berbeda tergantung lingkungan. Server dan browser punya akses data, waktu, locale, cookie, storage, dan DOM yang berbeda. Jika perbedaan itu dipakai langsung saat render, mismatch hampir pasti terjadi.
1. Akses window atau localStorage saat render
Ini kasus klasik. Server tidak punya objek browser seperti window, document, atau localStorage. Bahkan jika Anda memberi pengecekan kondisi, hasil render awal bisa tetap berbeda.
// Buruk: output render bergantung pada localStorage saat render awal
function ThemeBadge() {
const theme = typeof window !== 'undefined'
? localStorage.getItem('theme')
: 'light';
return <span>Tema: {theme}</span>;
}Di server, hasilnya light. Di browser, bisa dark. Teks awal berbeda, mismatch terjadi.
2. State berasal dari waktu, locale, atau random
Nilai seperti Date.now(), new Date(), Math.random(), atau formatting locale bisa berbeda antara server dan klien. Perbedaan timezone server dan browser juga sering memicu teks tanggal yang tidak sama.
// Buruk: waktu dihitung saat render
export default function Clock() {
return <p>{new Date().toLocaleString()}</p>;
}Jika server berada di timezone UTC dan browser di WIB, string tanggal dapat berbeda meski render terjadi hampir bersamaan.
3. Conditional markup berbasis cookie atau state browser yang tidak diseragamkan
Masalah terjadi saat server merender berdasarkan satu sumber data, tetapi klien memakai sumber lain saat render awal.
// Pola berisiko: server pakai props, client pakai document.cookie saat render
function PromoBanner({ serverHasConsent }) {
const clientHasConsent = typeof document !== 'undefined'
&& document.cookie.includes('consent=yes');
const hasConsent = clientHasConsent || serverHasConsent;
return hasConsent ? <button>Lanjut</button> : <p>Minta izin dulu</p>;
}Jika cookie berubah di browser tetapi belum tercermin di HTML server, struktur markup bisa berbeda.
4. Fetch ganda yang mengubah state terlalu cepat
Pada SSR modern, data idealnya sudah tersedia saat render server lalu dipakai ulang di klien. Mismatch bisa terjadi jika klien langsung melakukan fetch ulang pada mount dan hasilnya mengubah output sebelum hydration stabil, atau jika data awal tidak di-serialize dengan benar.
Masalah ini juga umum pada Inertia ketika page props dari server tidak dianggap sebagai single source of truth, lalu komponen melakukan request tambahan yang mengubah daftar, jumlah item, atau status loading terlalu dini.
5. Third-party script yang memodifikasi DOM
Script analytics, chat widget, editor, peta, atau iklan sering menyentuh DOM secara imperatif. Jika script dijalankan sebelum hydration selesai atau ditempel di area yang dirender framework, struktur node bisa berubah di luar kendali virtual DOM.
Intinya: hydration mismatch bukan sekadar warning kosmetik. Ia menandakan kontrak SSR dilanggar: server dan klien tidak sepakat tentang bentuk UI awal.
Prinsip utama perbaikan: render awal harus stabil, efek samping ditunda
Solusi paling aman adalah memisahkan dua fase dengan tegas:
- Render awal SSR + hydration: hanya pakai data yang tersedia dan konsisten di server serta klien.
- Efek setelah mount: baru akses API browser, storage, ukuran viewport, cookie klien, script pihak ketiga, atau sinkronisasi tambahan.
Dengan kata lain, jika suatu nilai hanya tersedia secara aman di browser, jangan jadikan ia penentu markup awal.
Pola perbaikan praktis untuk Next.js, Nuxt, dan Inertia
1. Gunakan SSR-safe default state
State awal harus punya nilai default yang aman dan deterministik. Setelah komponen benar-benar berada di browser, baru sinkronkan state sebenarnya.
// React / Next.js
import { useEffect, useState } from 'react';
export default function ThemeBadge() {
const [theme, setTheme] = useState('light'); // default aman untuk SSR
useEffect(() => {
const saved = window.localStorage.getItem('theme');
if (saved) setTheme(saved);
}, []);
return <span>Tema: {theme}</span>;
}Pola ini bekerja karena server dan render awal klien sama-sama menghasilkan light. Setelah hydration selesai, useEffect berjalan dan memperbarui state tanpa melanggar kontrak markup awal.
// Vue / Nuxt
<script setup>
import { ref, onMounted } from 'vue'
const theme = ref('light')
onMounted(() => {
const saved = window.localStorage.getItem('theme')
if (saved) theme.value = saved
})
</script>
<template>
<span>Tema: {{ theme }}</span>
</template>2. Tunda efek samping dengan useEffect atau onMounted
Semua akses ke window, document, storage, media query, dan API browser lain sebaiknya dipindah ke hook pasca-mount.
Kapan pola ini tepat?
- Membaca tema dari
localStorage - Mengukur ukuran viewport
- Mengakses cookie langsung dari browser
- Inisialisasi library DOM-only
- Memasang listener seperti
resizeatauscroll
Trade-off: UI bisa berubah sesaat setelah mount. Itu lebih aman daripada mismatch, tetapi kadang menimbulkan layout shift. Jika perubahan visual penting, pertimbangkan placeholder atau class awal yang berasal dari server.
3. Isolasi komponen client-only
Jika sebuah komponen memang tidak masuk akal untuk SSR—misalnya editor rich text, peta interaktif, visualisasi yang bergantung penuh pada DOM—isolasi komponen itu sebagai client-only.
Prinsipnya bukan “matikan SSR untuk semua”, melainkan batasi area non-deterministik ke pulau klien yang kecil.
Pola implementasi umum:
- Next.js: muat komponen secara dinamis hanya di klien untuk widget DOM-heavy.
- Nuxt: bungkus bagian tertentu dengan komponen client-only atau tunda mounting komponen browser-only.
- Inertia: render placeholder stabil dari server, lalu mount widget browser-only di dalam container setelah halaman siap.
Kapan dipilih?
- Library pihak ketiga mengubah DOM secara imperatif
- Komponen bergantung pada API browser sejak awal
- Memperbaiki SSR akan lebih kompleks daripada manfaatnya
Keterbatasan: konten di dalam komponen client-only tidak ikut SSR, sehingga ada konsekuensi SEO dan first paint untuk area itu.
4. Seragamkan sumber data antara server dan klien
Untuk cookie, locale, feature flag, atau session, usahakan server dan klien memakai sumber data awal yang sama. Jika server sudah membaca cookie lalu merender UI berdasarkan cookie itu, klien sebaiknya menerima nilai yang sama melalui props atau payload hydration, bukan menghitung ulang dari sumber lain saat render awal.
Pola aman:
- Server baca cookie/headers/request context
- Nilai diturunkan ke komponen sebagai props atau state awal
- Klien memakai nilai itu pada render awal
- Jika perlu sinkronisasi ulang, lakukan setelah mount
// Contoh pola React yang aman
function ConsentGate({ initialConsent }) {
const [consent, setConsent] = useState(initialConsent);
useEffect(() => {
const actual = document.cookie.includes('consent=yes');
if (actual !== consent) setConsent(actual);
}, [consent]);
return consent ? <button>Lanjut</button> : <p>Minta izin dulu</p>;
}Markup awal tetap konsisten dengan server, tetapi klien masih bisa melakukan koreksi setelah mount bila diperlukan.
5. Hindari nilai random dan waktu langsung di render
Jika Anda perlu menampilkan stempel waktu atau ID unik:
- Hitung di server dan kirim nilainya ke klien
- Atau render placeholder stabil, lalu isi nilainya setelah mount
- Jangan pakai
Math.random()atauDate.now()langsung dalam output SSR yang terlihat pengguna
// Lebih aman: timestamp berasal dari server
export default function PublishedAt({ isoString }) {
return <time dateTime={isoString}>{isoString}</time>;
}Jika ingin format lokal pengguna, render nilai server lebih dulu, lalu format ulang setelah mount bila memang dibutuhkan.
6. Kendalikan fetch agar tidak menghasilkan dua realitas UI
Fetch ganda tidak selalu salah, tetapi harus dirancang agar tidak mengubah markup terlalu cepat sebelum hydration selesai. Gunakan data SSR sebagai initial data, lalu revalidate setelah mount bila perlu.
Pedoman praktis:
- Jangan render state loading di klien jika server sudah punya data final
- Pastikan payload SSR dipakai ulang oleh klien
- Jika melakukan re-fetch, pertahankan UI awal sampai data baru benar-benar datang
- Hindari mengganti struktur list atau empty state pada frame pertama di browser
Pada Inertia, jadikan page props hasil server sebagai baseline. Jika komponen masih melakukan request tambahan, gunakan itu untuk pembaruan progresif, bukan untuk mengganti realitas awal yang baru saja di-SSR.
7. Batasi third-party script ke boundary yang jelas
Script pihak ketiga sebaiknya:
- Dijalankan setelah mount
- Diposisikan di container yang tidak diandalkan untuk hydration elemen lain
- Dibersihkan saat unmount bila library mendukung
- Tidak memodifikasi node yang juga dikelola framework
// React: mount widget di container terisolasi
import { useEffect, useRef } from 'react';
export default function ChatWidget() {
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
// contoh pseudo-code inisialisasi library pihak ketiga
const cleanup = window.initChatWidget?.(ref.current);
return () => {
if (typeof cleanup === 'function') cleanup();
};
}, []);
return <div ref={ref} data-widget-container />;
}Jangan biarkan script menyisip ke dalam subtree yang juga di-render SSR oleh framework, kecuali Anda benar-benar tahu urutan hidup komponennya.
Panduan spesifik per ekosistem
Next.js
- Pindahkan akses browser API ke
useEffect. - Untuk widget browser-only, pertimbangkan pemuatan dinamis di klien.
- Jangan gunakan nilai waktu, random, atau locale browser langsung di JSX SSR.
- Jika data berasal dari server, gunakan sebagai initial state dan hindari render loading ulang tanpa alasan.
Nuxt
- Pakai
onMounteduntuk efek browser-only. - Gunakan pola client-only untuk library DOM-heavy.
- Pastikan state awal dari server konsisten dengan nilai yang akan dipakai saat hydration.
- Waspadai perbedaan locale dan timezone saat formatting tanggal.
Inertia
- Perlakukan props halaman dari server sebagai sumber kebenaran awal.
- Jangan langsung mengganti markup berdasarkan
window, storage, atau cookie klien saat render awal. - Untuk widget pihak ketiga, mount secara terisolasi setelah halaman aktif.
- Jika memakai shared props untuk auth, locale, atau consent, pastikan logika klien tidak menghitung ulang versi berbeda saat hydration.
Checklist diagnosis hydration mismatch
Saat warning hydration muncul, gunakan checklist ini:
- Apakah ada akses browser API saat render?
Cariwindow,document,localStorage,sessionStorage,matchMedia. - Apakah ada nilai non-deterministik?
Carinew Date(),Date.now(),Math.random(), formatter locale. - Apakah markup bergantung pada cookie atau session yang dibaca berbeda di server dan klien?
- Apakah ada fetch ulang terlalu dini?
Periksa apakah SSR data diganti loading state atau hasil request lain saat boot. - Apakah ada third-party script yang memodifikasi DOM?
- Apakah struktur elemen berubah?
Bukan hanya teks; jumlah node, atribut, dan urutan child juga penting. - Apakah environment server dan browser memakai locale/timezone berbeda?
Strategi logging: bandingkan HTML server vs client
Debug hydration mismatch lebih mudah jika Anda bisa melihat dua hal: HTML yang dikirim server dan DOM/markup saat klien mulai hydrate. Tujuannya bukan membuat alat sempurna, tetapi mempersempit area yang berubah.
1. Tambahkan marker debug di subtree penting
Beri atribut seperti data-debug-id pada komponen yang dicurigai. Ini memudahkan pencarian node yang berubah.
return (
<section data-debug-id="promo-banner">
{content}
</section>
);2. Log props server yang dipakai untuk render awal
Untuk cookie, locale, auth, atau feature flag, log nilai yang diterima dari server dan nilai yang dibaca klien setelah mount. Jika berbeda, Anda sudah menemukan kandidat kuat penyebab mismatch.
useEffect(() => {
console.debug('client consent', document.cookie.includes('consent=yes'));
}, []);3. Ambil snapshot HTML sebelum dan sesudah mount pada area kecil
Di lingkungan development, Anda bisa mengambil innerHTML dari container tertentu setelah mount dan membandingkannya dengan ekspektasi state awal. Jangan lakukan ini untuk seluruh dokumen secara agresif di production karena mahal dan berisik.
useEffect(() => {
const el = document.querySelector('[data-debug-id="promo-banner"]');
if (el) {
console.debug('hydrated HTML', el.innerHTML);
}
}, []);4. Isolasi dengan eksperimen kecil
Matikan sementara bagian yang dicurigai:
- hapus formatting tanggal
- nonaktifkan widget pihak ketiga
- ganti cookie logic dengan konstanta
- matikan fetch pasca-mount
Jika warning hilang, Anda punya jalur diagnosis yang jelas. Pendekatan ini lebih efektif daripada menebak-nebak dari stack trace saja.
Contoh refactor: dari komponen rapuh ke SSR aman
Versi bermasalah
// Rentan mismatch: banyak keputusan diambil saat render
export default function HeaderPromo() {
const theme = typeof window !== 'undefined'
? localStorage.getItem('theme') || 'light'
: 'light';
const now = new Date().toLocaleString();
const hasConsent = typeof document !== 'undefined'
&& document.cookie.includes('consent=yes');
return (
<header>
<p>Tema: {theme}</p>
<p>Waktu: {now}</p>
{hasConsent ? <button>Buka promo</button> : <p>Butuh consent</p>}
</header>
);
}Versi yang lebih aman
import { useEffect, useState } from 'react';
export default function HeaderPromo({
initialTheme = 'light',
initialConsent = false,
serverTimestamp
}) {
const [theme, setTheme] = useState(initialTheme);
const [consent, setConsent] = useState(initialConsent);
const [localTime, setLocalTime] = useState(serverTimestamp);
useEffect(() => {
const savedTheme = window.localStorage.getItem('theme');
if (savedTheme) setTheme(savedTheme);
const actualConsent = document.cookie.includes('consent=yes');
if (actualConsent !== consent) setConsent(actualConsent);
setLocalTime(new Date(serverTimestamp).toLocaleString());
}, [serverTimestamp, consent]);
return (
<header data-debug-id="header-promo">
<p>Tema: {theme}</p>
<p>Waktu: {localTime}</p>
{consent ? <button>Buka promo</button> : <p>Butuh consent</p>}
</header>
);
}Perbaikan utamanya:
- state awal berasal dari server atau default stabil
- akses browser API dipindah ke
useEffect - waktu awal seragam karena memakai timestamp server
- sinkronisasi klien tetap dimungkinkan setelah mount
Kesalahan umum yang sering lolos code review
- Pengecekan
typeof windowdianggap cukup.
Ini mencegah crash di server, tetapi tidak otomatis mencegah mismatch jika output render tetap berbeda. - Menyimpan locale atau timezone di state hasil browser, lalu memakainya di render awal.
- Menganggap warning hydration aman diabaikan.
Kadang aplikasi tampak berjalan, tetapi event binding, UI flicker, atau bug state bisa muncul kemudian. - Meletakkan script pihak ketiga di subtree SSR tanpa isolasi.
- Memuat ulang data segera saat mount tanpa mempertahankan initial data dari server.
Ringkasan langkah praktis
- Jaga agar render awal deterministik dan sama di server serta klien.
- Jangan akses
window,document, storage, atau DOM saat render SSR. - Tunda efek samping ke
useEffectatauonMounted. - Pakai SSR-safe default state untuk nilai yang baru diketahui di browser.
- Seragamkan sumber data awal untuk cookie, locale, auth, dan feature flag.
- Isolasi komponen browser-only ke boundary client-only yang sempit.
- Gunakan data SSR sebagai baseline, lalu revalidate dengan hati-hati.
- Tambahkan logging dan marker debug untuk membandingkan perilaku server vs klien.
Jika Anda mengingat satu prinsip saja, pilih ini: hydration mismatch hampir selalu berawal dari komponen yang terlalu banyak “berinisiatif” sebelum waktunya. Seperti agent tanpa guardrail, efek samping klien yang berjalan saat render akan keluar dari ekspektasi sistem. Tugas Anda adalah memasang batas yang jelas: render awal harus stabil, dan semua hal yang bergantung pada browser harus terjadi setelah mount.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!