Hydration error pada banner consent hampir selalu berarti satu hal: markup yang dihasilkan server tidak identik dengan markup render pertama di browser. Pada komponen seperti banner cookie, dialog kebijakan privasi, atau consent wall, masalah ini sering muncul karena state awal ditentukan dari sumber yang hanya tersedia di client, misalnya localStorage, geolocation browser, eksperimen A/B, atau waktu saat render.

Masalah ini makin sering muncul karena UI legal/compliance berubah cepat mengikuti regulasi dan tekanan platform besar. Berita denda antitrust Google di Uni Eropa bisa menjadi pengingat bahwa kebutuhan compliance dapat berubah sewaktu-waktu, dan tim produk sering menambah logika consent secara bertahap. Jika perubahan tersebut langsung dimasukkan ke komponen SSR tanpa kontrak state yang jelas antara server dan client, hasilnya mudah berujung pada hydration mismatch, banner berkedip, atau dialog yang muncul-lalu-hilang.

Mengapa banner consent rawan hydration error

Banner consent punya karakteristik yang membuatnya berbeda dari komponen UI biasa:

  • State awal sering tidak pasti: server belum tentu tahu apakah user sudah menyetujui consent.
  • Sumber datanya tersebar: cookie, localStorage, header, locale, IP geolocation, feature flag, atau hasil eksperimen.
  • Markup sering kondisional: jika consent ada, banner hilang; jika belum, dialog dan backdrop muncul.
  • Dampak visual tinggi: sedikit mismatch saja langsung terlihat sebagai flicker atau layout shift.

Dalam SSR, browser akan menerima HTML awal dari server, lalu framework melakukan hydration dengan memasang event listener dan merekonsiliasi tree UI di client. Jika client melakukan render pertama dengan kondisi berbeda dari server, framework dapat menampilkan peringatan hydration error, membuang subtree tertentu, atau melakukan render ulang. Pada banner consent, efeknya sering berupa UI legal yang berkedip atau berubah setelah beberapa milidetik.

Sumber mismatch yang paling umum

1. Akses localStorage terlalu dini

Ini adalah penyebab paling sering. Server tidak punya akses ke window atau localStorage. Jika render server mengasumsikan consent belum ada, tetapi render pertama di client langsung membaca localStorage dan menyimpulkan consent sudah ada, maka markup awal berbeda.

Contoh pola yang bermasalah:

function ConsentBanner() {
  const accepted = localStorage.getItem('cookie_consent') === 'accepted'

  if (accepted) return null
  return <div>Kami menggunakan cookie...</div>
}

Pola ini salah untuk SSR karena pembacaan storage terjadi saat render, bukan setelah hydration selesai atau dari state awal yang diselaraskan.

2. Geolocation atau locale berbeda antara server dan client

Banner consent sering dipersonalisasi berdasarkan wilayah, misalnya hanya menampilkan dialog tertentu untuk pengguna dari EEA/UK atau mengubah teks berdasarkan locale. Masalah muncul jika server menebak negara dari header atau IP, tetapi client menghitung ulang dari navigator.language, timezone, atau endpoint geolocation yang hasilnya berbeda.

Jika server merender versi EU dan client memutuskan user bukan EU, maka tree UI berubah saat hydration.

3. Feature flag atau A/B test yang hanya tersedia di client

Tim produk sering menguji variasi copy, layout tombol, atau urutan aksi consent. Jika assignment eksperimen dilakukan di browser setelah halaman dimuat, SSR dan client bisa menghasilkan markup berbeda. Ini sangat berisiko jika perbedaannya bukan sekadar teks, tetapi struktur DOM: misalnya satu varian memakai modal, varian lain memakai sticky banner.

4. Nilai waktu saat render

Banner legal kadang bergantung pada waktu: menampilkan ulang setelah 180 hari, menunda muncul beberapa detik, atau mengubah konten berdasarkan tanggal efektif kebijakan. Jika nilai Date.now() dipakai langsung saat render server dan client, mismatch bisa terjadi. Bahkan perbedaan kecil dapat memengaruhi cabang kondisional.

5. Conditional markup yang berubah total

Masalah bukan hanya pada data, tetapi pada struktur. Misalnya server merender:

<div class="banner">
  <p>Kami menggunakan cookie</p>
  <button>Terima</button>
</div>

Namun client merender null atau modal dengan wrapper berbeda. Perubahan root element, jumlah child, urutan node, atau atribut penting adalah pemicu klasik hydration warning.

Prinsip dasar: server dan client harus sepakat pada render pertama

Solusi paling aman bukan “menonaktifkan SSR”, melainkan membuat render pertama di client identik dengan HTML dari server. Setelah hydration selesai, barulah state boleh diperbarui jika perlu.

Secara praktis, ada tiga strategi utama:

  1. Gunakan sumber state yang juga diketahui server, biasanya cookie request.
  2. Tahan render bagian dinamis sampai mounted, tetapi dengan placeholder yang stabil agar tidak flicker.
  3. Precompute keputusan consent di server lalu kirim sebagai props/page data ke client.

Untuk banner consent, strategi pertama biasanya paling stabil: simpan keputusan user di cookie yang dapat dibaca baik oleh server maupun browser.

Pola implementasi aman

Next.js: seed state dari cookie server, sinkronisasi ulang setelah mount

Intinya, jangan jadikan localStorage sebagai sumber kebenaran untuk render awal. Pakai cookie request untuk menentukan state SSR, lalu jika ingin mempertahankan salinan di client, lakukan sinkronisasi setelah mount.

import { useEffect, useState } from 'react'

export function ConsentBanner({ initialConsent }) {
  const [consent, setConsent] = useState(initialConsent)
  const [mounted, setMounted] = useState(false)

  useEffect(() => {
    setMounted(true)
    const stored = window.localStorage.getItem('cookie_consent')
    if (stored && stored !== consent) {
      setConsent(stored)
    }
  }, [])

  const accept = () => {
    document.cookie = 'cookie_consent=accepted; path=/; max-age=31536000; SameSite=Lax'
    window.localStorage.setItem('cookie_consent', 'accepted')
    setConsent('accepted')
  }

  if (consent === 'accepted') return null

  return (
    <div role="dialog" aria-live="polite">
      <p>Kami menggunakan cookie untuk analitik dan preferensi.</p>
      <button onClick={accept}>Terima</button>
      {!mounted && <span style={{display:'none'}}>syncing</span>}
    </div>
  )
}

Di sisi server, kirim initialConsent dari cookie request. Nama API pengambilan cookie berbeda tergantung router atau arsitektur yang dipakai, tetapi prinsipnya sama: baca cookie di server, lalu jadikan itu state awal komponen.

Mengapa ini aman? Karena render pertama client memakai initialConsent yang sama dengan SSR. Pembacaan localStorage baru terjadi setelah mount, sehingga tidak memengaruhi hydration.

Nuxt: gunakan cookie universal, hindari keputusan awal dari browser-only API

Dalam ekosistem Nuxt/Vue, pola amannya juga sama: ambil consent dari cookie yang tersedia di server dan client, lalu render berdasarkan nilai tersebut. Jika perlu cek storage atau geolocation lanjutan, lakukan di hook lifecycle client.

<script setup>
const consent = useCookie('cookie_consent')
const mounted = ref(false)

onMounted(() => {
  mounted.value = true
  const stored = localStorage.getItem('cookie_consent')
  if (stored && stored !== consent.value) {
    consent.value = stored
  }
})

function accept() {
  consent.value = 'accepted'
  localStorage.setItem('cookie_consent', 'accepted')
}
</script>

<template>
  <div v-if="consent !== 'accepted'" role="dialog">
    <p>Kami menggunakan cookie untuk pengalaman yang lebih konsisten.</p>
    <button @click="accept">Terima</button>
  </div>
</template>

Jika ada logika wilayah atau locale, lebih baik tentukan dulu di server atau middleware, lalu teruskan hasilnya sebagai state yang stabil. Jangan membuat SSR memutuskan satu wilayah, lalu client menghitung ulang dengan aturan berbeda saat render pertama.

Inertia: kirim consent state sebagai shared props

Pada aplikasi berbasis Laravel + Inertia, tempat yang baik untuk menyelaraskan state adalah shared props. Server membaca cookie dari request, lalu semua halaman menerima consent awal yang konsisten.

// Middleware / service provider conceptual example
Inertia::share('consent', function () use ($request) {
    return [
        'status' => $request->cookie('cookie_consent', 'unknown'),
        'region' => $request->attributes->get('region', 'unknown'),
    ];
});
import { usePage } from '@inertiajs/react'

export default function ConsentBanner() {
  const { consent } = usePage().props

  if (consent.status === 'accepted') return null

  return (
    <div role="dialog">
      <p>Situs ini menggunakan cookie.</p>
      <button>Terima</button>
    </div>
  )
}

Poin pentingnya bukan API spesifik, melainkan pola arsitekturnya: state awal banner datang dari request yang sama dengan SSR.

Strategi mencegah UI berkedip saat state consent belum pasti

Kadang server memang belum bisa memastikan state final, misalnya karena keputusan membutuhkan sinyal yang hanya tersedia di browser. Dalam kasus ini, tujuan Anda bukan menebak, tetapi menahan UI legal agar render awal tetap stabil.

1. Render shell yang stabil, bukan banner penuh yang spekulatif

Alih-alih menampilkan banner lengkap lalu menghapusnya setelah mount, tampilkan wrapper atau placeholder yang tinggi/ruangnya tetap. Setelah state final diketahui, isi kontennya bisa dimunculkan tanpa layout shift besar.

function ConsentSlot({ ready, showBanner }) {
  return (
    <div style={{ minHeight: 72 }} aria-hidden={!ready}>
      {ready && showBanner ? <ConsentBannerContent /> : null}
    </div>
  )
}

Ini berguna jika Anda benar-benar perlu menunggu perhitungan client-side. Trade-off-nya adalah ada ruang kosong singkat, tetapi lebih baik daripada banner yang muncul lalu lenyap.

2. Simpan consent di cookie, localStorage hanya cache sekunder

Jika persetujuan legal penting untuk SSR yang konsisten, cookie lebih cocok sebagai sumber kebenaran karena ikut terkirim pada request berikutnya. localStorage boleh tetap dipakai untuk kebutuhan client, tetapi jangan menjadi satu-satunya sumber state render awal.

3. Hindari conditional markup yang berubah drastis

Lebih aman menjaga struktur DOM tetap mirip, lalu ubah visibilitas atau kontennya setelah mount. Misalnya satu wrapper dialog selalu ada, tetapi kontennya kosong sampai state pasti. Jangan sering mengganti antara null, banner inline, dan modal penuh pada render pertama.

4. Server-side region resolution yang konsisten

Jika banner bergantung pada wilayah, hitung wilayah sekali di sisi server atau edge, lalu teruskan hasilnya ke halaman. Jangan membiarkan client melakukan evaluasi ulang dengan aturan berbeda kecuali memang diperlukan, dan jika perlu, lakukan setelah hydration dengan transisi yang terkontrol.

Langkah debug hydration error secara sistematis

1. Baca pesan mismatch, tapi jangan berhenti di sana

Framework biasanya memberi petunjuk seperti teks tidak cocok, jumlah node berbeda, atau atribut berubah. Gunakan itu untuk mengidentifikasi subtree yang bermasalah, tetapi akar masalah hampir selalu berada pada state awal yang tidak sinkron.

2. Log state render di server dan di client

Tambahkan log minimal untuk nilai yang memengaruhi banner:

  • status consent
  • region/locale
  • feature flag
  • timestamp atau tanggal efektif
  • apakah komponen sedang SSR atau client render

Tujuannya bukan logging permanen, tetapi membuktikan apakah server dan client memulai dari data yang sama.

3. Bandingkan HTML SSR dengan render pertama client

Buka View Source atau tangkap HTML hasil response, lalu bandingkan dengan DOM setelah hydration. Jika struktur root, urutan child, atau atribut penting berubah sebelum interaksi user, itulah kandidat mismatch.

4. Matikan faktor dinamis satu per satu

Nonaktifkan sementara:

  • pembacaan localStorage
  • geolocation client
  • A/B assignment client-only
  • penggunaan Date.now() saat render
  • conditional rendering yang bergantung pada browser API

Jika hydration error hilang setelah satu faktor dimatikan, Anda sudah menemukan sumber utamanya.

5. Pakai data uji yang memaksa cabang berbeda

Buat skenario seperti:

  • cookie consent ada, localStorage tidak ada
  • cookie tidak ada, localStorage accepted
  • region server = EU, locale browser = non-EU
  • flag A/B belum tersedia saat SSR

Hydration bug sering baru terlihat pada kombinasi keadaan yang “tidak ideal”, bukan pada happy path lokal developer.

Checklist reproduksi untuk bug yang sulit ditangkap

  1. Bersihkan cookie dan localStorage, lalu muat ulang halaman.
  2. Uji dengan JavaScript aktif dan nonaktif untuk melihat HTML SSR murni.
  3. Gunakan mode private/incognito agar state lama tidak bocor.
  4. Simulasikan region atau locale berbeda melalui header, VPN, atau stub backend bila tersedia.
  5. Throttle jaringan agar peralihan sebelum dan sesudah hydration lebih terlihat.
  6. Uji beberapa entry page, bukan hanya homepage, karena middleware atau shared props bisa berbeda.
  7. Periksa varian eksperimen jika aplikasi memakai feature flag.
  8. Bandingkan environment lokal dan staging; bug sering hanya muncul ketika edge/middleware geolocation aktif.

Kesalahan implementasi yang sering terjadi

Menganggap suppress warning sebagai solusi

Menyembunyikan warning hydration tidak memperbaiki akar masalah. Anda mungkin kehilangan sinyal penting, sementara user tetap melihat flicker atau state legal yang salah sesaat.

Menggantungkan state legal hanya pada client storage

Ini nyaman saat implementasi awal, tetapi buruk untuk SSR. Jika consent memengaruhi UI awal, cookie request hampir selalu lebih tepat.

Mencampur keputusan compliance dengan keputusan presentasi

Pisahkan:

  • Decision state: user sudah setuju atau belum, region apa, policy mana yang berlaku.
  • Presentation state: banner, modal, collapsible, sticky footer.

Jika decision state stabil dari server, Anda bebas mengubah presentasi tanpa memicu mismatch yang sama parah.

Menggunakan nilai waktu langsung di render

Jika perlu logika berbasis waktu, hitung di server dan kirim hasilnya, atau evaluasi setelah mount. Jangan menaruh Date.now() di cabang render SSR dan berharap nilainya tetap konsisten.

Arsitektur yang lebih tahan terhadap perubahan regulasi

Karena UI compliance sering berubah cepat, desain komponen consent sebaiknya tidak terlalu melekat pada sumber data browser-only. Beberapa praktik yang membantu:

  • Satukan contract state seperti { consentStatus, region, policyVersion, shouldPrompt }.
  • Hitung contract itu di server sedekat mungkin dengan request masuk.
  • Gunakan cookie sebagai sumber kebenaran lintas request.
  • Batasi eksperimen pada teks atau style jika assignment belum SSR-safe.
  • Tambahkan test SSR hydration untuk kasus consent accepted/rejected/unknown.

Dengan pola ini, saat tim legal meminta teks baru, tombol tambahan, atau logika wilayah yang berubah, Anda tidak perlu menulis ulang seluruh mekanisme hydration. Yang berubah cukup kontrak keputusan dan presentasinya.

Penutup

Hydration error pada banner consent bukan sekadar warning kosmetik. Ia menandakan bahwa server dan client tidak sepakat tentang state legal pada render pertama. Penyebab yang paling umum adalah akses localStorage terlalu dini, geolocation atau locale yang berbeda, feature flag client-only, penggunaan waktu saat render, dan conditional markup yang berubah total.

Solusi yang paling stabil adalah menyamakan sumber kebenaran antara SSR dan client, biasanya dengan cookie request atau props yang diturunkan dari server. Jika state awal memang belum pasti, tahan render dinamis dengan shell yang stabil agar tidak terjadi UI berkedip. Untuk framework seperti Next.js, Nuxt, dan Inertia, prinsipnya sama: render pertama harus deterministik.

Jika Anda hanya mengingat satu aturan: jangan biarkan banner consent mengambil keputusan render awal dari data yang tidak dimiliki server. Di situlah sebagian besar hydration mismatch bermula.