SSR stabil, hydration aman pada dasarnya berarti satu hal: HTML yang dirender di server harus merepresentasikan state awal yang sama dengan yang dipakai client saat pertama kali melakukan hydration. Jika server merender satu struktur, tetapi client menghitung state awal yang berbeda, framework akan mendeteksi mismatch, memunculkan warning, atau lebih buruk, menambal DOM dengan hasil yang tidak Anda harapkan.

Masalah ini sering terlihat sepele. Hanya beda teks tanggal, hanya beda tombol login, hanya beda layout kecil. Namun seperti banyak keputusan tooling yang gagal bukan karena fitur besar melainkan karena friksi kecil dalam workflow, bug hydration juga mahal justru karena sifatnya yang tampak remeh. Ia menghabiskan waktu debugging, membuat warning sulit direproduksi, dan menurunkan kepercayaan developer pada SSR.

Artikel ini fokus pada cara mencegah render mismatch dan bug hydration yang muncul karena state awal berbeda antara server dan client. Contohnya dibahas secara framework-agnostic agar relevan untuk Next.js, Nuxt, maupun Inertia.

Mengapa mismatch terjadi saat SSR dan hydration

Pada alur SSR, server menghasilkan HTML awal. Lalu di browser, JavaScript framework mengambil alih markup itu melalui proses hydration. Agar proses ini aman, render pertama di client harus menghasilkan output yang secara logis sama dengan output server.

Mismatch terjadi ketika salah satu hal berikut berbeda antara dua lingkungan tersebut:

  • Input render: data awal, cookie, auth state, feature flag, locale, timezone.
  • Waktu evaluasi: nilai dihitung saat request di server, tetapi dihitung ulang saat hydration di client.
  • Sumber data: server mengambil dari request, client mengambil dari browser API.
  • Cabang UI: conditional rendering menghasilkan struktur DOM berbeda.

Efeknya tidak selalu hanya warning. Dalam kasus tertentu, event handler bisa terpasang pada node yang tidak sesuai, state lokal bisa ter-reset, atau UI berkedip karena client mengganti hasil render server.

Penyebab umum state awal berbeda

1. Data bergantung waktu, timezone, atau locale

Waktu adalah sumber mismatch yang paling sering diremehkan. Server dan client bisa memiliki timezone berbeda, locale berbeda, atau sekadar menghitung timestamp pada milidetik yang berbeda.

Antipola umum:

// Antipola: nilai waktu dihitung langsung saat render di kedua sisi
function TimeLabel() {
  return <span>{new Date().toLocaleString()}</span>
}

Jika server merender pukul 10:00:00 dan client melakukan hydration pada 10:00:01, hasilnya sudah bisa berbeda. Begitu juga jika locale server berbeda dari locale browser.

Perbaikannya:

  • Hitung nilai waktu di server sekali, lalu kirim sebagai data awal yang dipakai client.
  • Gunakan format yang stabil untuk initial render, misalnya ISO string atau timestamp mentah.
  • Jika ingin format sesuai locale browser, render placeholder stabil dulu, lalu format ulang setelah hydration.
// Lebih aman: server mengirim createdAt sebagai ISO string
function PublishedAt({ createdAt }) {
  return <time dateTime={createdAt}>{createdAt}</time>
}

// Setelah hydration, Anda bisa enhance tampilannya di client bila perlu

Prinsipnya: jangan biarkan render pertama bergantung pada waktu saat ini atau locale browser jika server tidak menggunakan nilai yang sama.

2. Akses window, document, localStorage terlalu dini

API browser tidak tersedia saat SSR. Bahkan jika Anda menambahkan guard agar kode tidak error di server, hasil render tetap bisa mismatch bila state awal di client berasal dari localStorage tetapi server memakai default lain.

Antipola:

function ThemeToggle() {
  const theme = localStorage.getItem('theme') || 'light'
  return <div data-theme={theme}>...</div>
}

Di server, kode ini tidak bisa dijalankan. Jika Anda mengubahnya menjadi guard sederhana seperti typeof window !== 'undefined', server mungkin merender light, tetapi client langsung merender dark. Hasilnya tetap mismatch.

Pendekatan yang lebih aman:

  • Gunakan nilai awal yang konsisten antara server dan client.
  • Sinkronkan preferensi browser setelah hydration melalui effect/hook lifecycle client.
  • Jika preferensi penting untuk first paint, pertimbangkan memindahkannya ke cookie agar server juga bisa membacanya.
function ThemeToggle({ initialTheme }) {
  const [theme, setTheme] = useState(initialTheme)

  useEffect(() => {
    const stored = window.localStorage.getItem('theme')
    if (stored && stored !== theme) setTheme(stored)
  }, [])

  return <div data-theme={theme}>...</div>
}

Jika cookie tersedia di request, initialTheme bisa diisi server agar render pertama tetap konsisten.

3. Random value saat render

Nilai acak seperti Math.random(), generator ID, atau token sementara tidak boleh dipanggil bebas saat render awal jika hasilnya memengaruhi markup.

Antipola:

function PromoBanner() {
  const variant = Math.random() > 0.5 ? 'A' : 'B'
  return <section>Promo {variant}</section>
}

Server bisa memilih A, client bisa memilih B.

Perbaikan:

  • Generate nilai acak di server lalu kirim sebagai prop.
  • Gunakan identifier yang stabil dari data, bukan random saat render.
  • Jika random hanya untuk enhancement visual non-kritis, jalankan setelah hydration.

4. Feature flag tidak konsisten

Feature flag sering diambil dari sumber berbeda: server dari environment atau service internal, client dari SDK browser atau cache lokal. Jika evaluasinya tidak identik, UI awal bisa berbeda.

Masalah tipikal:

  • Server menilai flag true, client SDK belum siap lalu default ke false.
  • Segmentasi user dihitung berdasarkan atribut yang hanya tersedia di client.
  • Flag dibaca asinkron di client tanpa state awal hasil evaluasi server.

Perbaikannya adalah serialisasi hasil evaluasi flag yang dipakai server ke payload halaman, lalu client menggunakan nilai itu sebagai initial state sebelum melakukan refresh/rekonsiliasi lebih lanjut.

5. Auth state berbeda

Auth mismatch sangat umum pada SSR. Server mungkin tahu user sudah login dari cookie sesi, tetapi client belum selesai memuat profil, atau malah sebaliknya: client membaca token dari storage yang tidak dipakai server.

Antipola:

function Navbar() {
  const token = typeof window !== 'undefined'
    ? localStorage.getItem('token')
    : null

  return token ? <UserMenu /> : <LoginButton />
}

Server merender tombol login, client langsung menggantinya dengan menu user.

Perbaikan:

  • Gunakan satu sumber kebenaran untuk auth awal, idealnya berbasis request seperti cookie atau session server.
  • Kirim snapshot auth minimal pada SSR, misalnya { authenticated: true, userName: '...' }.
  • Jika status auth client masih perlu diverifikasi ulang, jangan ubah struktur UI awal secara agresif sebelum verifikasi selesai.

6. Conditional rendering yang mengubah struktur DOM

Mengganti teks biasanya masih lebih mudah dipulihkan daripada mengubah struktur node. Masalah membesar saat perbedaan state awal menyebabkan cabang render yang sangat berbeda.

Antipola:

function Sidebar({ isMobile }) {
  return isMobile
    ? <button>Open menu</button>
    : <nav>...full menu...</nav>
}

Jika isMobile ditentukan dari window.innerWidth di client, server tidak punya nilai yang sama. Hasil render awal bisa sepenuhnya berbeda.

Lebih aman:

  • Hindari memakai ukuran viewport untuk menentukan struktur SSR awal.
  • Gunakan CSS responsif untuk perbedaan layout jika memungkinkan.
  • Jika cabang UI memang harus berbeda, render struktur dasar yang sama lalu adaptasi setelah hydration.

Antipola dan perbaikan yang paling sering ditemui

Antipola: menghitung state awal langsung dari browser API

const [collapsed, setCollapsed] = useState(
  window.localStorage.getItem('sidebar') === '1'
)

Masalah: server tidak bisa menghitung nilai yang sama.

Perbaikan: mulai dari nilai stabil, lalu sinkronkan di lifecycle client.

const [collapsed, setCollapsed] = useState(initialCollapsed)

useEffect(() => {
  const stored = window.localStorage.getItem('sidebar')
  if (stored != null) setCollapsed(stored === '1')
}, [])

Antipola: memformat data berbeda di server dan client

<p>{price.toLocaleString()}</p>

Masalah: locale default bisa berbeda.

Perbaikan: tentukan locale eksplisit atau kirim string yang sudah diformat dari server jika format awal harus identik.

<p>{new Intl.NumberFormat('id-ID').format(price)}</p>

Namun tetap ingat: bila environment server tidak menyediakan konfigurasi locale yang identik, strategi paling aman untuk first render adalah serialisasi hasil format dari server.

Antipola: render berdasarkan nilai acak atau waktu saat ini

const greeting = new Date().getHours() < 12 ? 'Pagi' : 'Siang'

Masalah: server dan client bisa menilai jam yang berbeda.

Perbaikan: hitung di server dan kirim sebagai prop, atau render netral dulu.

Antipola: menggantungkan first render pada hasil fetch client

Jika server merender placeholder sederhana, tetapi client pertama kali merender data hasil cache lokal atau store yang berbeda, mismatch dapat muncul.

Perbaikan: pastikan store client di-hydrate dari payload server yang sama, bukan dari perhitungan ulang yang bisa menghasilkan struktur berbeda.

Checklist diagnosis render mismatch dan bug hydration

Saat warning hydration muncul, jangan langsung menebak-nebak. Pakai checklist berikut agar investigasi lebih sistematis.

  1. Bandingkan HTML server dan render client pertama
    Lihat output markup sebelum effect berjalan. Fokus pada node yang diperingatkan framework.
  2. Tanya: state awal ini berasal dari mana?
    Apakah dari request, cookie, localStorage, waktu sekarang, random, feature flag SDK, atau hasil fetch client?
  3. Periksa nilai yang tidak deterministik
    Cari pemakaian Date, Math.random(), generator ID, locale default, timezone, viewport, dan browser API.
  4. Audit conditional rendering
    Apakah server dan client bisa memilih cabang JSX/template yang berbeda?
  5. Verifikasi serialisasi state SSR
    Apakah data yang dipakai server benar-benar dioper ke client sebagai initial state?
  6. Periksa auth dan feature flag
    Apakah evaluasi awal berasal dari sumber yang sama? Apakah client punya fallback berbeda?
  7. Uji di environment berbeda
    Coba timezone berbeda, locale berbeda, mode incognito, user login vs logout, flag on vs off.
  8. Matikan enhancement client sementara
    Jika mismatch hilang setelah komponen tertentu dinonaktifkan, fokus pada bagian yang membaca browser state terlalu dini.

Pola implementasi yang lebih aman

1. Gunakan server sebagai sumber state awal

Untuk semua hal yang memengaruhi markup awal, biasakan bertanya: bisakah server mengetahui nilai ini saat request? Jika ya, render dari server dan kirim snapshot-nya ke client.

Contoh data yang cocok untuk pendekatan ini:

  • auth/session
  • theme dari cookie
  • feature flag yang dievaluasi di server
  • locale dari request
  • timestamp atau string terformat yang ingin stabil

2. Pisahkan state initial dan state enhanced

Tidak semua preferensi browser perlu diketahui saat SSR. Untuk data yang hanya ada di client, gunakan dua lapis state:

  • initial state: aman untuk SSR, stabil, deterministik
  • enhanced state: disinkronkan setelah hydration

Ini mengurangi mismatch sekaligus membuat transisi lebih eksplisit.

3. Hindari perbedaan struktur DOM pada first render

Jika Anda belum tahu state sesungguhnya sampai client siap, pertahankan struktur markup awal sesederhana mungkin. Lebih baik merender shell yang konsisten daripada dua cabang DOM yang sangat berbeda.

4. Gunakan cookie bila state perlu dibaca server dan client

localStorage mudah dipakai, tetapi tidak membantu SSR. Jika preferensi memengaruhi tampilan awal, cookie sering menjadi kompromi praktis karena bisa dibaca saat request dan tetap tersedia di browser.

5. Serialisasi payload SSR secara eksplisit

Pada arsitektur SSR apa pun, usahakan ada payload yang jelas untuk state awal: auth, flags, locale, user preferences, dan data halaman. Client seharusnya melakukan hydrate dari payload ini, bukan menebak ulang.

Panduan pencegahan untuk Next.js, Nuxt, dan Inertia secara framework-agnostic

Walau detail API berbeda, pola pencegahannya sama.

Next.js

  • Pastikan data yang memengaruhi markup awal diambil pada fase server dan diteruskan ke komponen sebagai props atau state awal.
  • Jangan membaca window, document, atau localStorage saat menentukan initial render.
  • Untuk komponen yang memang hanya masuk akal di client, pertimbangkan render bertahap agar SSR tidak memproduksi output yang menipu.
  • Jika memakai store global, hydrate store dari payload server, bukan dari inisialisasi client yang berbeda.

Nuxt

  • Pisahkan data yang aman untuk SSR dari state yang hanya valid di client.
  • Gunakan mekanisme state bersama yang bisa diisi saat server render lalu digunakan ulang saat client hydration.
  • Hindari plugin atau composable yang membaca browser API terlalu cepat jika nilainya ikut menentukan template awal.

Inertia

  • Karena halaman menerima props dari server, manfaatkan itu sebagai sumber kebenaran initial state.
  • Jangan langsung mengganti props berbasis data dari browser pada render pertama.
  • Untuk auth, flash data, feature flag, dan preferensi, lebih aman membagikan snapshot dari server daripada menghitung ulang di komponen.

Jika diringkas dalam satu aturan lintas framework: segala sesuatu yang mengubah markup awal harus berasal dari input yang sama di server dan client.

Debugging tips yang benar-benar membantu

  • Log nilai initial state di server dan client. Jangan hanya log setelah effect berjalan.
  • Cetak payload SSR untuk memastikan state yang dipakai server benar-benar sampai ke browser.
  • Buat reproduksi minimal dengan menghapus fitur satu per satu: auth, flags, formatting, storage, responsive branching.
  • Uji timezone dan locale berbeda karena bug ini sering tidak muncul di mesin developer yang konfigurasinya kebetulan mirip dengan server.
  • Waspadai warning yang tampak kecil. Satu mismatch teks sering menjadi indikator ada model state awal yang tidak disiplin.

Penutup

Bug hydration jarang terasa "arsitektural" pada pandangan pertama. Sering kali ia muncul dari detail kecil: format tanggal, token di storage, flag yang telat siap, atau cabang UI yang berubah karena viewport. Tetapi biaya nyatanya besar karena mengganggu workflow, memakan waktu debugging, dan membuat SSR terasa tidak dapat dipercaya.

Untuk menjaga SSR stabil, hydration aman, fokuslah pada determinisme render awal. Jadikan server sebagai sumber state awal untuk hal-hal yang memengaruhi markup, pisahkan enhancement client dari initial render, dan hindari membaca sumber data yang hanya tersedia di browser sebelum hydration selesai.

Jika tim Anda sering mengalami mismatch, biasanya masalahnya bukan pada satu framework tertentu, melainkan pada disiplin desain state awal. Begitu prinsip ini konsisten diterapkan, warning hydration biasanya turun drastis dan pengalaman developer menjadi jauh lebih tenang.