Pada aplikasi SSR modern, hydration menghubungkan HTML yang sudah dirender di server dengan JavaScript di browser agar UI menjadi interaktif. Masalah muncul ketika output render di server berbeda dari output render pertama di client. Akibatnya bukan sekadar warning di console: tombol bisa kehilangan state, konten bisa meloncat, event handler menempel ke elemen yang salah, atau UI terlihat seperti “bertindak sendiri”.

Itulah inti dari SSR deterministik: untuk komponen yang di-render di server, hasil render pertama di browser harus sama. Analogi yang berguna adalah kasus sistem otonom yang diberi ruang efek samping tanpa pagar pengaman. Begitu input awal tidak terkontrol, perilaku berikutnya bisa menyimpang jauh dari ekspektasi. Dalam frontend SSR, sumber “lepas kendali” itu biasanya bukan AI, melainkan nilai waktu, random, akses API browser, hasil fetch yang berubah, feature flag yang tidak sinkron, atau state awal yang berbeda antara server dan client.

Artikel ini fokus pada cara mencegah mismatch tersebut secara praktis, termasuk pola implementasi di Next.js, Nuxt, dan SvelteKit, contoh before/after, checklist debugging, serta panduan kapan SSR lebih tepat daripada CSR.

Mengapa hydration gagal dan UI tampak menyimpang?

Hydration mengasumsikan bahwa struktur DOM dan isi penting dari hasil render server sama dengan hasil render awal di browser. Jika asumsi ini rusak, framework biasanya akan:

  • mengeluarkan warning mismatch,
  • membuang sebagian hasil SSR lalu merender ulang di client, atau
  • tetap melanjutkan tetapi dengan DOM dan state yang tidak sepenuhnya sinkron.

Dampaknya bisa halus atau parah. Halus berarti teks berubah setelah load. Parah berarti state komponen tidak sesuai, elemen berpindah, focus hilang, atau interaksi pengguna tertabrak re-render.

Pola umumnya sederhana: jika fungsi render bergantung pada input yang berbeda antara server dan browser, maka SSR tidak deterministik.

Penyebab mismatch paling umum

1. Waktu dan tanggal

Render seperti new Date(), Date.now(), atau format waktu berbasis locale sering menghasilkan output berbeda. Perbedaan zona waktu, locale server, dan jeda beberapa milidetik saja cukup untuk membuat teks tidak sama.

// Buruk: hasil server dan client bisa berbeda saat hydration
export default function Clock() {
  return <p>{new Date().toLocaleString()}</p>
}

Masalah ini sering terlihat sepele, tetapi sangat umum pada widget “last updated”, jam relatif, countdown, dan label waktu transaksi.

2. Nilai acak

Pemanggilan Math.random(), generator ID acak, atau token sementara saat render akan hampir selalu berbeda antara server dan client.

// Buruk
const color = Math.random() > 0.5 ? 'red' : 'blue'

Jika nilai ini memengaruhi struktur DOM, atribut, class, atau key daftar, mismatch akan mudah terjadi.

3. Akses ke window, document, localStorage, matchMedia

API browser tidak tersedia di server. Bahkan jika Anda melindunginya dengan typeof window !== 'undefined', hasil render bisa tetap berbeda. Contohnya, di server Anda merender tema default terang, tetapi di client komponen langsung membaca localStorage dan merender tema gelap pada render pertama.

// Masih berisiko mismatch bila memengaruhi output render awal
const theme = typeof window !== 'undefined'
  ? localStorage.getItem('theme')
  : 'light'

4. Hasil fetch berbeda antara server dan client

Jika data diambil ulang di browser saat hydration dan hasilnya berbeda dari yang dipakai server, UI dapat berubah sebelum pengguna sempat berinteraksi. Penyebabnya bisa karena data memang berubah cepat, cache tidak konsisten, header autentikasi berbeda, geolokasi berbeda, atau request server dan browser menuju edge/backend yang tidak identik.

5. Feature flag dan eksperimen yang tidak sinkron

SSR sering dipakai bersama A/B testing atau feature flag. Jika server memilih varian A tetapi client mengevaluasi flag menjadi varian B pada render awal, layout akan berubah. Ini sering terjadi bila evaluasi flag di browser bergantung pada storage, cookie yang belum tersedia dalam cara yang sama, atau SDK pihak ketiga yang baru selesai inisialisasi setelah halaman dimuat.

6. State awal yang tidak sinkron

Masalah klasik lainnya adalah state awal di server tidak diserialisasi dengan benar ke client. Server merender daftar 10 item, tetapi store di browser dimulai kosong lalu memuat ulang menjadi 10 item. Secara visual ini bisa menimbulkan flash, atau lebih buruk lagi, DOM mismatch.

Pola inti: SSR deterministik

Tujuan utamanya bukan membuat UI statis, melainkan memastikan render pertama konsisten. Setelah hydration selesai, UI boleh berubah karena efek client, polling, atau interaksi pengguna. Beberapa pola berikut adalah fondasinya.

1. Gunakan input render yang stabil

Jangan hitung nilai yang berubah-ubah langsung di template/render function jika nilai itu ikut SSR. Hitung di server sekali, kirim sebagai props atau state ter-serialisasi, lalu gunakan nilai yang sama di client untuk render pertama.

// Lebih aman: waktu ditentukan di server, dipakai sama di client
export async function getServerSideProps() {
  return {
    props: {
      generatedAt: new Date().toISOString()
    }
  }
}

export default function Page({ generatedAt }) {
  return <p>Dibuat pada: {generatedAt}</p>
}

Jika Anda ingin menampilkan format lokal pengguna, render dulu nilai server yang stabil, lalu ubah setelah mount di browser.

2. Sediakan server-safe defaults

Untuk nilai yang hanya diketahui di browser, render default yang aman dan konsisten di server. Setelah komponen mount, baru sinkronkan dengan nilai sebenarnya.

Contohnya untuk tema, ukuran viewport, preferensi media query, atau status dari localStorage.

// Contoh React/Next.js sederhana
import { useEffect, useState } from 'react'

export default function ThemeLabel() {
  const [theme, setTheme] = useState('light') // default server-safe

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

  return <span>Tema: {theme}</span>
}

Trade-off-nya adalah potensi flash of incorrect state. Untuk kasus seperti tema global, lebih baik sinkronkan preferensi lewat cookie atau data inline dari server agar render awal langsung benar.

3. Buat boundary client-only untuk bagian yang memang tidak bisa deterministik

Beberapa komponen memang bergantung total pada browser: chart yang membaca ukuran container, editor rich text, peta, komponen yang sangat bergantung pada DOM, atau widget berbasis SDK pihak ketiga. Daripada memaksa SSR lalu gagal hydration, lebih baik jadikan client-only boundary.

Penerapannya berbeda per framework, tetapi prinsipnya sama: render placeholder yang stabil di server, lalu muat komponen penuh hanya di browser.

  • Next.js: gunakan dynamic import tanpa SSR untuk komponen yang benar-benar client-only.
  • Nuxt: gunakan pembungkus client-only untuk komponen yang tidak aman dirender di server.
  • SvelteKit: batasi logika browser-only pada lifecycle client atau pisahkan komponen yang hanya dirender setelah mount.

Gunakan pola ini seperlunya. Jika terlalu banyak komponen dibuat client-only, manfaat SSR untuk TTFB, SEO, dan konten awal akan berkurang.

4. Serialisasi state awal secara eksplisit

Jika server sudah memiliki data yang dipakai untuk render, kirim state tersebut ke client dan pakai ulang saat inisialisasi store. Jangan biarkan browser menebak atau melakukan fetch pertama yang hasilnya mungkin berbeda.

Pola yang sehat:

  1. ambil data di server,
  2. render HTML menggunakan data itu,
  3. serialisasi data ke payload halaman,
  4. inisialisasi store/client cache dari payload yang sama,
  5. baru lakukan revalidasi setelah hydration jika perlu.

Ini penting untuk daftar produk, profil pengguna, hasil pencarian, atau dashboard dengan data awal.

5. Samakan evaluasi feature flag

Feature flag harus dievaluasi dari sumber kebenaran yang sama untuk server dan client. Jika keputusan varian dibuat di server, kirim hasil evaluasinya ke client dan gunakan hasil itu untuk render pertama. Hindari mengevaluasi ulang dengan logika berbeda saat hydration.

Catatan: jika flag bergantung pada identitas pengguna, pastikan konteks autentikasi yang dipakai server sama dengan yang tersedia saat browser melanjutkan sesi. Perbedaan kecil pada cookie atau header dapat memicu varian berbeda.

Contoh before/after: dari mismatch ke render stabil

Kasus 1: waktu relatif

// Before: mismatch karena waktu dihitung saat render
export default function ArticleMeta() {
  const minutes = Math.floor((Date.now() - new Date('2025-06-20').getTime()) / 60000)
  return <span>{minutes} menit lalu</span>
}
// After: server kirim timestamp stabil, client memperbarui setelah mount bila perlu
import { useEffect, useState } from 'react'

export default function ArticleMeta({ publishedAt, nowFromServer }) {
  const [label, setLabel] = useState(formatRelative(publishedAt, nowFromServer))

  useEffect(() => {
    setLabel(formatRelative(publishedAt, Date.now()))
  }, [publishedAt])

  return <span>{label}</span>
}

function formatRelative(from, to) {
  const minutes = Math.floor((to - new Date(from).getTime()) / 60000)
  return `${minutes} menit lalu`
}

Mengapa ini bekerja? Karena render pertama di server dan client sama-sama memakai nowFromServer. Perubahan baru terjadi setelah hydration, sehingga bukan mismatch.

Kasus 2: membaca localStorage untuk tema

// Before
export default function Header() {
  const theme = typeof window !== 'undefined'
    ? localStorage.getItem('theme') || 'light'
    : 'light'

  return <header data-theme={theme}>...</header>
}
// After: nilai awal dipasok server, localStorage hanya untuk sinkronisasi lanjutan
import { useEffect, useState } from 'react'

export default function Header({ initialTheme }) {
  const [theme, setTheme] = useState(initialTheme)

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

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

Lebih baik lagi jika initialTheme berasal dari cookie yang dibaca server, sehingga tidak ada kilatan tema yang salah pada render awal.

Kasus 3: fetch ganda yang hasilnya berbeda

// Before: server dan client sama-sama fetch tanpa jaminan hasil identik
export default function Products() {
  const { data } = useProducts()
  return <ProductList items={data || []} />
}
// After: data awal dari server, client revalidate setelah hydration
export default function Products({ initialProducts }) {
  const { data } = useProducts({ initialData: initialProducts })
  return <ProductList items={data} />
}

Pola ini menurunkan risiko mismatch sekaligus menjaga data tetap bisa diperbarui setelah halaman hidup di browser.

Panduan praktis per framework

Next.js

  • Jangan panggil Date.now(), Math.random(), atau API browser langsung dalam output SSR kecuali nilainya dipasok server.
  • Gunakan props atau data server sebagai sumber nilai awal untuk komponen yang ikut SSR.
  • Untuk komponen yang bergantung penuh pada browser, pindahkan ke boundary client-only.
  • Jika memakai store atau cache data, isi dari payload server terlebih dahulu sebelum revalidasi.
  • Perhatikan juga perbedaan locale, timezone, dan cookie antara environment server dan browser.

Nuxt

  • Pastikan data yang dipakai pada SSR juga tersedia sebagai payload untuk hydration.
  • Komponen yang tidak aman untuk server sebaiknya dibungkus dalam boundary client-only.
  • Hindari membaca state berbasis browser saat render awal; pakai default stabil atau injeksi dari request server.
  • Untuk feature flag, evaluasikan di satu tempat lalu teruskan hasilnya ke komponen.

SvelteKit

  • Tempatkan logika browser-only pada tahap lifecycle client, bukan pada render SSR.
  • Gunakan data dari load/server sebagai input render awal yang stabil.
  • Jika perlu state awal di client, serialisasikan dari data server alih-alih membuat ulang dari nol.
  • Waspadai reactive statement yang diam-diam membaca API browser dan mengubah output terlalu dini.

Meskipun API dan istilah berbeda, ketiga framework mengikuti hukum yang sama: render server dan render client pertama harus menerima input yang sama.

Logging mismatch dan strategi observability

Masalah hydration sering luput dari pengujian lokal karena hanya muncul pada locale tertentu, user tertentu, atau jalur data tertentu. Karena itu, logging sangat penting.

Apa yang sebaiknya dicatat

  • route dan komponen yang memicu mismatch,
  • payload data server yang dipakai untuk render,
  • nilai penentu render awal: locale, timezone, cookie tema, feature flag, user segment,
  • apakah ada fallback client-only atau re-render penuh setelah hydration,
  • snapshot HTML/props ringkas bila memungkinkan dan aman.

Praktik yang berguna

  • Pastikan warning hydration di development tidak diabaikan.
  • Naikkan warning mismatch penting ke sistem monitoring frontend.
  • Tambahkan logging di boundary data: saat server memilih flag, saat state awal diserialisasi, dan saat client melakukan revalidasi pertama.
  • Untuk bug yang sulit direproduksi, bandingkan payload SSR dengan state store setelah boot.

Prinsipnya: jangan hanya melihat gejala DOM berbeda; catat input apa yang membuat render berbeda.

Checklist debugging hydration error

  1. Periksa render function: adakah Date.now(), new Date(), Math.random(), atau generator ID saat render?
  2. Audit akses API browser: adakah window, document, localStorage, sessionStorage, matchMedia yang memengaruhi output awal?
  3. Bandingkan data server vs client: apakah browser melakukan fetch ulang dengan hasil berbeda sebelum hydration selesai?
  4. Cek feature flag: apakah varian dipilih ulang di browser dengan konteks berbeda?
  5. Verifikasi state awal: apakah store di client diinisialisasi dari payload SSR yang sama?
  6. Periksa locale dan timezone: apakah format tanggal, angka, atau mata uang identik?
  7. Lihat key pada list: adakah key acak atau tidak stabil yang berubah antara server dan client?
  8. Uji dengan JavaScript lambat: throttle jaringan/CPU untuk melihat flash dan urutan re-render.
  9. Isolasi komponen: jika perlu, ubah sementara menjadi client-only untuk memastikan sumber mismatch memang di komponen itu.
  10. Review HTML invalid: struktur markup yang tidak valid juga bisa memicu perilaku hydration aneh.

Kapan memakai SSR, kapan lebih baik CSR?

Pilih SSR bila

  • konten awal perlu cepat terlihat,
  • halaman penting untuk SEO atau share preview,
  • data awal bisa diketahui di server,
  • Anda dapat menjaga render deterministik.

Pilih CSR atau client-only untuk bagian tertentu bila

  • UI sangat bergantung pada API browser,
  • konten tidak penting untuk SEO,
  • hasil render awal memang personal dan hanya diketahui di perangkat pengguna,
  • biaya menjaga determinisme lebih tinggi daripada manfaat SSR untuk komponen tersebut.

Pendekatan yang sering paling sehat adalah hybrid: SSR untuk shell, konten utama, dan data yang stabil; CSR/client-only untuk widget yang memang browser-centric.

Kesalahan umum yang sering terulang

  • Menganggap typeof window !== 'undefined' otomatis menyelesaikan mismatch. Ini hanya mencegah crash di server, bukan menjamin output sama.
  • Menggunakan nilai acak sebagai key list atau ID elemen saat SSR.
  • Mengambil data dua kali tanpa menyelaraskan hasil server dan client.
  • Membiarkan SDK flag/analytics pihak ketiga mengubah layout pada render awal.
  • Mengabaikan warning hydration karena “secara visual masih jalan”. Biasanya bug akan muncul kemudian sebagai state liar atau interaksi tidak konsisten.

Penutup

Pelajaran teknisnya sederhana: UI SSR bisa “lepas kendali” bukan karena framework buruk, tetapi karena input render awal tidak dipagari. SSR deterministik berarti server dan browser sepakat dulu pada render pertama; perubahan dinamis baru dilakukan setelah hydration dengan sengaja dan terukur.

Jika Anda ingin mencegah hydration error pada Next.js, Nuxt, atau SvelteKit, mulailah dari lima hal ini: gunakan input render yang stabil, sediakan server-safe defaults, pindahkan komponen yang tidak deterministik ke client-only boundary, serialisasikan state awal dengan benar, dan catat mismatch sebagai sinyal produksi yang nyata. Dengan itu, UI tidak lagi “bertindak sendiri” saat aplikasi hidup di browser.