SSR Browser Shell sering dipakai untuk membuat antarmuka aplikasi yang terlihat seperti browser: ada tab bar, address bar, toolbar, panel konten, dan state mirip window desktop. Masalahnya, UI seperti ini sangat mudah memicu hydration error jika HTML hasil render server tidak persis sama dengan render awal di client.

Solusinya bukan mematikan SSR untuk seluruh halaman, tetapi memastikan bagian yang di-SSR menghasilkan output awal yang deterministik, lalu menambahkan perilaku interaktif secara bertahap di client. Artikel ini membahas penyebab mismatch yang paling umum, pola aman yang bisa dipakai, contoh implementasi, dan kapan komponen tertentu memang lebih baik tidak di-SSR.

Apa yang menyebabkan hydration error pada UI mirip browser?

Hydration terjadi saat framework mengambil HTML hasil SSR lalu menghubungkannya dengan komponen interaktif di browser. Error muncul ketika struktur DOM, teks, atribut, atau urutan node pada render awal client berbeda dari HTML yang dikirim server.

Pada UI shell mirip browser, sumber masalah biasanya lebih banyak daripada halaman biasa karena state awal sering bergantung pada lingkungan browser.

1. Akses window atau localStorage terlalu dini

Server tidak punya objek window, document, atau localStorage. Jika state awal tab, ukuran panel, atau preferensi tema dihitung langsung dari API browser saat render pertama, hasil render server dan client akan berbeda.

// Bermasalah: state awal bergantung pada localStorage saat render pertama
const initialTabs = JSON.parse(localStorage.getItem('tabs') || '[]')

Di server, kode ini gagal atau harus dihindari. Di client, nilainya ada. Akibatnya DOM awal bisa berbeda.

2. Nilai waktu, random, dan ukuran layar yang tidak deterministik

Hydration mismatch juga sering terjadi ketika komponen merender nilai yang berubah-ubah:

  • Date.now() atau new Date() langsung di template
  • Math.random() untuk key, id, atau state awal
  • window.innerWidth untuk menentukan layout awal
  • perhitungan berbasis DPI, touch capability, atau user agent yang hanya tersedia di client

Jika server menghasilkan satu nilai dan client menghasilkan nilai lain pada render pertama, mismatch hampir pasti terjadi.

3. State tab awal berbeda antara server dan client

UI mirip browser umumnya memiliki array tab, tab aktif, riwayat halaman, pinned tab, atau split view. Jika server merender tab default tetapi client langsung memulihkan tab dari penyimpanan lokal, DOM awal akan berubah sebelum hydration selesai.

Contoh klasiknya:

  • SSR merender 1 tab kosong
  • Client membaca localStorage dan mendapatkan 4 tab tersimpan
  • Render awal client langsung menghasilkan 4 elemen tab
  • Hydration gagal karena HTML server hanya punya 1 tab

4. Layout kondisional berbasis capability browser

Komponen seperti resizable sidebar, custom title bar, gesture area, atau panel yang hanya aktif pada pointer tertentu sering dirender berdasarkan capability browser:

  • apakah layar sentuh tersedia
  • apakah pointer halus atau kasar
  • dukungan API tertentu
  • ukuran viewport saat ini

Jika capability ini menentukan struktur DOM, maka SSR dan client bisa menghasilkan markup yang berbeda.

Prinsip aman untuk SSR Browser Shell

1. Buat initial state yang deterministik

Render awal server dan render awal client harus memakai state yang sama. Untuk UI shell, artinya jangan mengambil state tab, ukuran, atau preferensi dinamis sebelum hydration selesai.

Gunakan state awal yang stabil, misalnya:

  • satu tab default
  • toolbar standar tanpa ukuran berbasis viewport
  • sidebar dalam keadaan tertutup atau ukuran tetap
  • placeholder untuk data yang baru dipulihkan setelah mount

Setelah komponen ter-mount di client, baru lakukan pemulihan state dari storage atau API browser.

2. Gunakan client-only boundary untuk bagian yang memang bergantung pada browser

Tidak semua bagian UI shell harus di-SSR. Jika satu komponen sangat bergantung pada DOM browser, ukuran viewport, drag-and-drop pointer, atau state persisten lokal yang kompleks, bungkus komponen itu dalam boundary client-only.

Tujuannya bukan menyembunyikan masalah, tetapi memisahkan:

  • bagian yang aman di-SSR: frame statis, header, struktur panel, placeholder
  • bagian yang hanya masuk akal di client: tab manager dinamis, resize observer, shortcut handler, panel berbasis capability

3. Terapkan progressive enhancement

Anggap SSR sebagai shell dasar yang stabil. Setelah client siap, tingkatkan fitur secara bertahap:

  1. SSR mengirim struktur browser shell yang konsisten
  2. Client melakukan hydration tanpa perubahan struktur besar
  3. Efek setelah mount memulihkan tab, ukuran panel, tema, dan preferensi lain
  4. Fitur berbasis capability diaktifkan setelah deteksi di browser

Pola ini lebih aman daripada mencoba membuat SSR menebak seluruh kondisi client.

4. Gunakan placeholder yang stabil

Jika daftar tab, thumbnail, atau panel history belum bisa ditentukan secara aman di server, tampilkan placeholder yang bentuknya konsisten di SSR dan render awal client.

Contoh placeholder yang aman:

  • satu tab bertuliskan Loading tabs…
  • toolbar dengan tinggi tetap
  • panel kanan kosong dengan skeleton
  • konten utama dengan area placeholder yang dimensinya tidak berubah drastis

Placeholder stabil mengurangi mismatch sekaligus mencegah layout shift berlebihan.

5. Pisahkan logic SSR dan logic client

Masalah hydration sering muncul karena komponen melakukan terlalu banyak hal sekaligus: menghitung state awal, membaca storage, mendeteksi capability, dan merender struktur dinamis dalam satu tempat.

Lebih aman jika dipisah menjadi:

  • SSR-safe state: data default yang valid di server dan client
  • client bootstrap: efek setelah mount untuk membaca storage, ukuran layar, atau API browser
  • derived UI state: hasil akhir setelah data client tersedia

Contoh implementasi pola aman

Contoh dasar: state tab deterministik lalu dipulihkan setelah mount

Contoh berikut menggunakan pola umum yang bisa diterapkan di React/Next.js. Intinya, render awal selalu memakai satu tab default. Setelah komponen ter-mount, data tab yang tersimpan baru dimuat dari localStorage.

const DEFAULT_TABS = [
  { id: 'welcome', title: 'Welcome', url: '/' }
]

function BrowserShell() {
  const [tabs, setTabs] = useState(DEFAULT_TABS)
  const [activeTabId, setActiveTabId] = useState('welcome')
  const [isHydrated, setIsHydrated] = useState(false)

  useEffect(() => {
    setIsHydrated(true)

    try {
      const raw = window.localStorage.getItem('browser-shell-state')
      if (!raw) return

      const saved = JSON.parse(raw)
      if (Array.isArray(saved.tabs) && saved.tabs.length > 0) {
        setTabs(saved.tabs)
        setActiveTabId(saved.activeTabId || saved.tabs[0].id)
      }
    } catch {
      // fallback ke state default
    }
  }, [])

  useEffect(() => {
    if (!isHydrated) return
    window.localStorage.setItem(
      'browser-shell-state',
      JSON.stringify({ tabs, activeTabId })
    )
  }, [tabs, activeTabId, isHydrated])

  return (
    <div className="browser-shell">
      <TabBar tabs={tabs} activeTabId={activeTabId} />
      <main>
        <TabContent activeTabId={activeTabId} />
      </main>
    </div>
  )
}

Mengapa pola ini aman?

  • SSR dan render awal client sama-sama memakai DEFAULT_TABS
  • localStorage baru diakses di useEffect, artinya hanya setelah mount di browser
  • Hydration terjadi terhadap markup yang konsisten
  • State persisten baru menggantikan tampilan setelah client siap

Contoh layout responsif tanpa menentukan struktur DOM dari window.innerWidth

Kesalahan umum adalah menggunakan window.innerWidth untuk memutuskan apakah sidebar dirender atau tidak saat render pertama. Lebih aman jika struktur DOM tetap ada, lalu CSS dan enhancement client yang mengatur perilakunya.

function ShellLayout() {
  return (
    <div className="shell">
      <aside className="shell__sidebar" data-enhanced="false">
        Sidebar
      </aside>
      <section className="shell__content">Content</section>
    </div>
  )
}
.shell {
  display: grid;
  grid-template-columns: 280px 1fr;
}

@media (max-width: 768px) {
  .shell {
    grid-template-columns: 1fr;
  }

  .shell__sidebar {
    display: none;
  }
}

Dengan pendekatan ini, SSR tidak perlu tahu ukuran viewport. CSS menangani responsivitas tanpa mengubah struktur render React/Vue/Svelte pada fase awal.

Contoh deteksi capability setelah mount

Jika Anda perlu mengaktifkan fitur khusus, seperti drag handle yang hanya berguna pada pointer halus, lakukan deteksi setelah mount dan jadikan sebagai enhancement, bukan penentu struktur SSR.

function useFinePointer() {
  const [finePointer, setFinePointer] = useState(false)

  useEffect(() => {
    const media = window.matchMedia('(pointer: fine)')
    setFinePointer(media.matches)

    const onChange = (event) => setFinePointer(event.matches)
    media.addEventListener?.('change', onChange)
    return () => media.removeEventListener?.('change', onChange)
  }, [])

  return finePointer
}

Gunakan hasilnya untuk mengaktifkan perilaku tambahan, bukan untuk merender struktur dasar yang berbeda total.

Pola implementasi untuk Next.js, Nuxt, dan SvelteKit

Next.js

Di Next.js, strategi aman biasanya terdiri dari tiga lapisan:

  • komponen server atau SSR-safe shell untuk struktur dasar
  • komponen client untuk state interaktif
  • dynamic import dengan SSR dimatikan untuk bagian yang benar-benar browser-only

Contoh pemisahan komponen browser-only:

import dynamic from 'next/dynamic'

const ClientTabManager = dynamic(() => import('./ClientTabManager'), {
  ssr: false
})

export default function Page() {
  return (
    <div className="browser-shell">
      <Header />
      <ClientTabManager />
    </div>
  )
}

Pakai pendekatan ini jika komponen memang tidak punya nilai SSR yang berarti, misalnya panel tab yang seluruh isinya dipulihkan dari storage lokal dan sangat tergantung pada event browser.

Namun, jangan berlebihan mematikan SSR. Jika tab bar masih bisa menampilkan placeholder yang stabil, lebih baik SSR shell dasarnya agar halaman tetap cepat tampil dan SEO/internal navigation tidak terganggu.

Nuxt

Di Nuxt, gunakan komponen client-only untuk bagian yang tidak aman di server. Untuk shell utama, pertahankan markup SSR yang stabil. Data dari localStorage, matchMedia, atau ukuran viewport sebaiknya dibaca di hook lifecycle client.

Prinsipnya sama:

  • hindari akses API browser pada evaluasi modul atau state awal universal
  • sediakan fallback SSR yang stabil
  • render komponen browser-only hanya di client jika memang perlu

SvelteKit

Di SvelteKit, Anda bisa memisahkan code path server dan browser dengan pemeriksaan environment yang sesuai, lalu menjalankan logika browser di lifecycle client. Untuk komponen shell, pastikan markup awal tidak bergantung pada nilai yang hanya ada di browser.

Jika satu komponen sepenuhnya interaktif dan tidak berguna tanpa browser API, pertimbangkan untuk merender fallback SSR sederhana lalu mount versi client setelahnya.

Kapan komponen tertentu sebaiknya tidak di-SSR?

Tidak semua komponen layak dipaksa SSR. Pada konteks UI mirip browser, pertimbangkan untuk tidak meng-SSR komponen tertentu jika memenuhi salah satu kondisi berikut:

  • seluruh state awal berasal dari localStorage, IndexedDB, atau session browser
  • markup awal sangat bergantung pada ukuran viewport aktual
  • fitur utama bergantung pada drag-and-drop kompleks, selection API, atau measurement DOM
  • isi komponen tidak relevan untuk SEO atau first paint semantik
  • membuat fallback SSR yang benar-benar konsisten jauh lebih rumit daripada manfaatnya

Contohnya:

  • pengelola tab dengan urutan drag-drop dan snapshot state lokal
  • panel dock yang ukurannya dihitung dari elemen aktual di DOM
  • preview area yang bergantung pada observer browser dan layout measurement

Sebaliknya, komponen berikut biasanya tetap layak di-SSR:

  • kerangka header dan toolbar
  • struktur panel utama
  • placeholder tab
  • konten halaman utama yang tidak bergantung pada browser-only API

Kesalahan umum yang sering tidak disadari

Menggunakan key yang tidak stabil

Jangan membuat key dengan Math.random() atau timestamp saat render. Gunakan id yang tetap dan bisa diprediksi.

Merender waktu saat ini di UI awal

Jam, timestamp, atau label seperti “dibuka 2 detik lalu” sebaiknya tidak dihitung pada render awal SSR jika akan langsung berbeda saat client render. Render placeholder dulu, lalu isi setelah mount.

Membaca browser API di scope modul

Kode seperti ini berbahaya karena dieksekusi saat modul dimuat, termasuk di server:

// Hindari
const isTouch = window.matchMedia('(pointer: coarse)').matches

Pindahkan ke efek atau hook client-only.

Mengubah struktur DOM besar setelah mount

Perubahan kecil setelah hydration masih bisa diterima, tetapi jika SSR merender satu struktur dan client langsung mengganti seluruh subtree, Anda akan melihat warning, flicker, atau event binding yang tidak konsisten.

Checklist debugging hydration error

Gunakan checklist berikut saat membangun SSR Browser Shell di Next.js, Nuxt, atau SvelteKit:

  1. Bandingkan output awal server dan client. Apakah jumlah tab, teks, class, atribut, atau urutan elemen berubah?
  2. Cari akses browser API. Telusuri penggunaan window, document, localStorage, matchMedia, ResizeObserver, dan measurement DOM.
  3. Audit nilai non-deterministik. Periksa Date.now(), new Date(), Math.random(), id dinamis, dan key yang berubah.
  4. Periksa state default. Pastikan state awal di server dan client identik sebelum efek berjalan.
  5. Jangan jadikan viewport sebagai penentu DOM awal. Gunakan CSS responsif atau enhancement setelah mount.
  6. Pisahkan komponen browser-only. Jika sangat sulit dibuat SSR-safe, gunakan client-only boundary.
  7. Tambahkan log sementara. Cetak state awal pada server dan client untuk memastikan keduanya sama sebelum hydration.
  8. Uji mode produksi. Beberapa gejala hydration lebih jelas di build produksi daripada mode development.

Tip praktis: saat satu komponen terus memicu mismatch, sederhanakan dulu menjadi markup statis. Lalu aktifkan kembali fitur satu per satu sampai sumber ketidaksesuaian ditemukan.

Strategi yang paling realistis untuk UI mirip PowerFox

Untuk antarmuka yang meniru browser desktop, strategi yang paling stabil biasanya seperti ini:

  1. SSR hanya merender shell dasar: header, tab strip placeholder, toolbar, area konten utama.
  2. State awal dibuat deterministik: satu tab default, ukuran panel tetap, tanpa pembacaan storage.
  3. Setelah mount, client memulihkan tab, active state, layout preference, dan enhancement berbasis capability.
  4. Komponen yang sangat browser-dependent, seperti drag tab manager atau layout measurement kompleks, dipisahkan ke boundary client-only.

Pendekatan ini menjaga dua hal sekaligus: manfaat SSR tetap ada, dan hydration tidak rusak oleh state yang hanya diketahui browser.

Penutup

Mencegah hydration error pada UI mirip browser bukan soal menghindari SSR sepenuhnya, tetapi soal memastikan render awal bersifat stabil, deterministik, dan tidak bergantung pada API browser. Penyebab paling umum biasanya berasal dari akses window atau localStorage terlalu dini, nilai waktu atau viewport yang berubah, state tab awal yang tidak sama, dan layout kondisional berbasis capability browser.

Jika Anda membangun SSR Browser Shell, mulailah dari shell yang aman di server, lalu tambahkan interaktivitas sebagai progressive enhancement di client. Itu adalah cara paling praktis untuk mendapatkan UI kaya seperti browser tanpa terjebak render mismatch saat hydration.