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:

  1. Render awal SSR + hydration: hanya pakai data yang tersedia dan konsisten di server serta klien.
  2. 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 resize atau scroll

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() atau Date.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 onMounted untuk 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:

  1. Apakah ada akses browser API saat render?
    Cari window, document, localStorage, sessionStorage, matchMedia.
  2. Apakah ada nilai non-deterministik?
    Cari new Date(), Date.now(), Math.random(), formatter locale.
  3. Apakah markup bergantung pada cookie atau session yang dibaca berbeda di server dan klien?
  4. Apakah ada fetch ulang terlalu dini?
    Periksa apakah SSR data diganti loading state atau hasil request lain saat boot.
  5. Apakah ada third-party script yang memodifikasi DOM?
  6. Apakah struktur elemen berubah?
    Bukan hanya teks; jumlah node, atribut, dan urutan child juga penting.
  7. 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 window dianggap 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 useEffect atau onMounted.
  • 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.