Masalah SSR dan hydration pada UI login biasanya terlihat sederhana, tetapi efeknya membingungkan pengguna: tombol Login sempat muncul lalu berubah menjadi Logout, avatar berkedip, atau header menampilkan status autentikasi yang tidak konsisten selama beberapa ratus milidetik. Akar masalahnya hampir selalu sama: HTML awal dari server dibuat dengan data yang berbeda dari data yang tersedia di browser saat aplikasi selesai mount.

Jika state autentikasi hanya dibaca dari localStorage, hanya tersedia setelah JavaScript berjalan di client. Sebaliknya, SSR hanya bisa mengandalkan request saat itu, misalnya cookie, header, atau hasil fetch session di server. Ketika dua sumber ini tidak sinkron, hydration akan memperbarui DOM dan UI tampak "berubah sendiri". Fokus artikel ini adalah cara mendiagnosis dan memperbaiki mismatch tersebut tanpa masuk terlalu jauh ke topik keamanan.

Gejala yang Paling Sering Muncul

Beberapa tanda bahwa masalah Anda berasal dari perbedaan SSR dan hydration:

  • Tombol login/logout berubah setelah page load. Server merender tombol Login, lalu client menggantinya menjadi Logout.
  • Avatar atau nama user berkedip. SSR menampilkan placeholder anonim, lalu client membaca session dan menampilkan avatar.
  • Navbar berbeda antara hard refresh dan navigasi antar-halaman. Saat pindah halaman di client semuanya tampak benar, tetapi saat refresh tampilan awal berbeda.
  • Warning hydration mismatch di console, terutama jika struktur HTML berubah, bukan sekadar teks.
  • State loading tidak konsisten. Sebagian komponen menganggap user sudah login, komponen lain masih anonim pada frame awal.

Gejala-gejala ini sering muncul di frontend modern yang memakai SSR, baik di Next.js, Nuxt, maupun arsitektur serupa.

Akar Masalah: Mengapa SSR dan Client Bisa Berbeda?

1. Server dan browser membaca sumber data yang berbeda

Ini penyebab paling umum. Misalnya:

  • Server hanya tahu cookie yang ikut dalam request.
  • Client membaca localStorage, memory store, atau hasil fetch session setelah mount.

Jika token atau flag login hanya ada di localStorage, SSR tidak punya akses ke data itu. Server lalu merender UI sebagai guest. Setelah hydration, browser membaca localStorage dan mengubah UI menjadi authenticated.

2. Cookie tersedia, tetapi state user belum diturunkan ke render awal

Kasus lain: session sebenarnya ada di cookie, namun komponen SSR tidak menggunakan informasi itu untuk membentuk auth state awal. Akibatnya server tetap merender tampilan guest, lalu client melakukan fetch /session dan mengoreksinya.

Dengan kata lain, masalahnya bukan session tidak ada, melainkan render awal tidak memakai session yang sudah tersedia di server.

3. Cookie httpOnly tidak bisa dibaca oleh JavaScript client

Ini sering menimbulkan kebingungan. Cookie httpOnly memang tidak dapat dibaca dari JavaScript di browser. Itu berarti pola seperti "cek cookie langsung di client untuk menentukan login" tidak akan bekerja. Jika server memakai cookie httpOnly sebagai sumber session, maka penentuan state autentikasi sebaiknya dilakukan di server atau lewat endpoint session yang konsisten.

4. Race condition saat fetch session

Contoh alur yang rawan:

  1. SSR merender sebagai guest.
  2. Client mount.
  3. Komponen A memanggil fetch session.
  4. Komponen B sudah lebih dulu membaca store default yang masih kosong.
  5. Header menampilkan Login, lalu berubah setelah fetch selesai.

Masalah ini bukan sekadar latensi, melainkan urutan pembacaan state yang tidak stabil.

5. Conditional rendering terlalu dini

Contoh pola yang sering memicu flicker:

if (isLoggedIn) {
  return <UserMenu />
}

return <LoginButton />

Jika isLoggedIn default-nya false di client sebelum session selesai dimuat, maka komponen akan selalu sempat merender guest state. Ketika data autentikasi datang, UI berubah. Secara teknis ini mungkin "benar", tetapi dari sisi UX terlihat seperti bug.

Contoh Alur Masalah di Next.js atau Nuxt

Secara umum, alurnya mirip di banyak framework SSR:

  1. User membuka halaman.
  2. Server menerima request dan membuat HTML awal.
  3. Browser menampilkan HTML hasil SSR.
  4. JavaScript dimuat, hydration berjalan.
  5. Komponen client membaca state tambahan: store, localStorage, atau fetch session.
  6. UI diperbarui jika state baru berbeda dari hasil SSR.

Masalah muncul pada langkah 5 dan 6. Bila sumber state autentikasi baru tersedia setelah mount, maka HTML awal hampir pasti berbeda dengan UI akhir.

Contoh pola yang rawan di Next.js

"use client"

import { useEffect, useState } from "react"

export default function AuthButton() {
  const [isLoggedIn, setIsLoggedIn] = useState(false)

  useEffect(() => {
    const token = window.localStorage.getItem("token")
    setIsLoggedIn(Boolean(token))
  }, [])

  return isLoggedIn ? <button>Logout</button> : <button>Login</button>
}

Kenapa ini bermasalah? Karena render awal akan selalu memakai false. Saat SSR atau frame awal hydration, tombol Login muncul. Setelah useEffect berjalan, UI bisa berubah menjadi Logout.

Contoh pola yang rawan di Nuxt

<script setup>
const isLoggedIn = ref(false)

onMounted(() => {
  isLoggedIn.value = Boolean(localStorage.getItem('token'))
})
</script>

<template>
  <button v-if="isLoggedIn">Logout</button>
  <button v-else>Login</button>
</template>

Pola ini memiliki masalah yang sama: state auth baru diketahui setelah mounted, sehingga tampilan awal tidak stabil.

Cara Mereproduksi Bug Secara Sengaja

Debug lebih mudah jika bug bisa direproduksi dengan konsisten. Gunakan langkah berikut:

1. Uji hard refresh, bukan hanya navigasi client-side

SSR baru benar-benar terlihat saat Anda membuka halaman langsung atau menekan refresh. Navigasi antar-halaman dalam SPA sering menyembunyikan masalah karena store client sudah terisi.

2. Tambahkan jeda pada fetch session

Jika Anda memakai endpoint session, tambahkan delay sementara di server development atau gunakan throttling di DevTools. Tujuannya untuk memperjelas fase:

  • HTML awal dari server
  • hydration
  • session selesai dimuat

Dengan delay, Anda bisa melihat apakah UI guest memang muncul terlalu dini.

3. Log sumber auth state di server dan client

Catat nilai yang dipakai masing-masing sisi. Misalnya:

  • Apakah server melihat cookie session?
  • Apakah client membaca token dari localStorage?
  • Kapan store berubah dari unknown ke authenticated?

Jangan hanya log true/false. Log juga sumber nilainya.

4. Bandingkan HTML awal dengan DOM setelah hydration

Lihat View Source atau respons HTML awal dari network tab, lalu bandingkan dengan DOM setelah halaman interaktif. Jika server mengirim tombol Login tetapi DOM akhir menjadi Logout, mismatch ada pada data awal render.

Checklist Debug SSR dan Hydration untuk UI Login

  • Apa sumber kebenaran auth state? Cookie, endpoint session, store memory, atau localStorage?
  • Apakah sumber itu tersedia di server saat SSR? Jika tidak, jangan paksa SSR membuat keputusan final tentang login.
  • Apakah komponen merender guest/authenticated terlalu cepat? Pertimbangkan state unknown atau placeholder stabil.
  • Apakah ada lebih dari satu mekanisme pengisian auth state? Misalnya store diisi dari cookie sekaligus dari fetch terpisah.
  • Apakah endpoint session dipanggil berkali-kali? Ini bisa menimbulkan update beruntun dan flicker tambahan.
  • Apakah perbedaan hanya teks, atau struktur elemen ikut berubah? Perubahan struktur lebih berisiko memicu warning hydration.
  • Apakah bug hanya muncul saat refresh? Jika ya, besar kemungkinan penyebabnya ada di fase SSR versus mount client.
  • Apakah cookie ikut terkirim pada request SSR dan request session? Jika tidak, server dan client bisa melihat user yang berbeda.

Pola Perbaikan yang Aman

1. Gunakan server-derived auth state untuk render awal

Ini pendekatan paling stabil jika autentikasi berbasis cookie/session yang bisa diakses server. Intinya, server menentukan status awal user dan meneruskannya ke komponen. Dengan begitu, HTML awal dan state hydration punya titik awal yang sama.

Secara umum alurnya:

  1. Baca cookie atau session di server.
  2. Bangun objek auth awal, misalnya { status: "authenticated", user } atau { status: "guest" }.
  3. Berikan nilai itu ke komponen atau store awal.
  4. Hydration memakai state yang sama sebelum ada fetch tambahan.

Kenapa ini bekerja? Karena keputusan render awal tidak lagi bergantung pada data yang hanya tersedia setelah mount.

Jika session memang bisa diketahui di server, usahakan header/navbar juga memakai data server tersebut, bukan memulai dari guest lalu mengoreksi di client.

2. Pakai state tiga tahap: unknown, guest, authenticated

Kesalahan umum adalah memakai boolean isLoggedIn dengan default false. Default ini membuat UI guest dirender sebelum data autentikasi benar-benar diketahui. Lebih aman memakai state eksplisit:

type AuthState =
  | { status: "unknown" }
  | { status: "guest" }
  | { status: "authenticated", user: { name: string, avatarUrl?: string } }

Dengan model ini, komponen tidak perlu menebak. Saat status masih unknown, tampilkan placeholder yang stabil.

3. Render placeholder yang stabil, bukan langsung Login atau Logout

Jika status autentikasi memang belum dapat dipastikan saat SSR, tampilkan UI netral yang tidak menipu pengguna. Misalnya:

  • skeleton avatar
  • slot kosong dengan ukuran tetap
  • tombol disabled bertuliskan Memuat...
  • placeholder menu akun

Tujuannya bukan sekadar estetika, tetapi mencegah perubahan makna UI antara server dan client. Placeholder stabil juga mengurangi layout shift.

function AuthAction({ auth }) {
  if (auth.status === "unknown") {
    return <div className="auth-placeholder" aria-busy="true" />
  }

  if (auth.status === "authenticated") {
    return <UserMenu user={auth.user} />
  }

  return <LoginButton />
}

4. Gunakan mounted guard bila state hanya valid di client

Untuk data yang memang eksklusif milik browser, misalnya preferensi yang hanya tersimpan di localStorage, Anda bisa menunda render bagian tertentu sampai komponen selesai mount. Ini bukan solusi universal, tetapi efektif jika Anda sadar bahwa data tersebut tidak cocok dijadikan dasar SSR.

"use client"

import { useEffect, useState } from "react"

export default function ClientOnlyAuthHint() {
  const [mounted, setMounted] = useState(false)

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) {
    return <div className="auth-placeholder" />
  }

  const token = window.localStorage.getItem("token")
  return token ? <button>Logout</button> : <button>Login</button>
}

Trade-off: pendekatan ini menghindari mismatch, tetapi Anda mengorbankan SSR untuk bagian tersebut. Karena itu, cocok untuk komponen kecil, bukan sebagai fondasi utama status autentikasi aplikasi.

5. Pisahkan komponen client-only untuk bagian yang tidak bisa disamakan

Jika header global sulit sepenuhnya diselaraskan, pecah menjadi dua bagian:

  • bagian SSR yang stabil, misalnya logo, navigasi umum
  • bagian client-only untuk widget akun yang benar-benar bergantung pada state browser

Dengan pemisahan ini, area yang berpotensi berubah dibatasi ke komponen kecil, sehingga dampak visual dan risiko mismatch lebih terkontrol.

6. Hindari fetch session ganda yang saling menimpa

Sering terjadi store auth diinisialisasi dari props server, lalu tetap melakukan fetch session saat mount tanpa syarat. Jika hasil fetch datang dengan timing yang berbeda atau sementara mengembalikan state kosong, UI bisa berkedip lagi.

Prinsipnya:

  • jika server sudah memberi auth state yang cukup, gunakan sebagai baseline
  • revalidasi di client hanya bila perlu
  • pastikan status loading dan hasil fetch tidak menimpa state valid secara sembarangan

Contoh Pola Implementasi yang Lebih Stabil

Server menentukan auth awal, client hanya melanjutkan

Contoh pseudocode generik:

// server
const session = await readSessionFromRequest(request)
const initialAuth = session
  ? { status: "authenticated", user: { name: session.user.name } }
  : { status: "guest" }

renderPage({ initialAuth })
// client
function App({ initialAuth }) {
  const [auth, setAuth] = useState(initialAuth)

  // optional: revalidate session in background if needed
  // but do not reset to guest while revalidating

  return <Header auth={auth} />
}

Kenapa pola ini aman? Karena server dan client memulai dari state yang sama. Jika nanti ada revalidasi, perubahannya bersifat koreksi lanjutan, bukan pergantian identitas UI tepat setelah mount.

Jika server tidak bisa tahu status final

Dalam beberapa arsitektur, server memang tidak punya informasi cukup untuk memutuskan user login atau tidak pada render awal. Dalam kondisi itu, jangan render state guest atau authenticated secara tegas. Pakai status unknown dan tampilkan placeholder stabil sampai client selesai menentukan status final.

Ini lebih jujur secara teknis dan lebih baik secara UX daripada menampilkan tombol yang salah lalu menggantinya.

Kesalahan Umum yang Perlu Dihindari

  • Menggunakan false sebagai default auth state padahal artinya belum diketahui.
  • Membaca localStorage untuk keputusan SSR. Server tidak punya akses ke sana.
  • Menganggap navigasi client-side mewakili perilaku SSR. Selalu uji hard refresh.
  • Mengubah struktur DOM antara loading dan loaded tanpa placeholder yang konsisten.
  • Menyebar logika auth ke banyak komponen sehingga masing-masing melakukan fetch sendiri.
  • Mengabaikan warning hydration karena tampak "hanya flicker". Biasanya itu tanda data awal tidak konsisten.

Panduan Praktis Memilih Solusi

Pilih server-derived auth state jika:

  • session tersedia melalui cookie/request
  • Anda ingin navbar dan header stabil sejak first paint
  • komponen auth adalah bagian penting dari layout SSR

Pilih placeholder + mounted guard jika:

  • state hanya tersedia di browser
  • komponen kecil dan tidak kritikal untuk SSR
  • Anda ingin menghindari mismatch tanpa memaksakan server menebak status auth

Pilih client-only separation jika:

  • bagian auth sulit diselaraskan dengan SSR
  • Anda ingin membatasi area yang berubah setelah mount
  • kompromi SEO/SSR pada bagian kecil masih dapat diterima

Penutup

Masalah UI login yang berubah setelah hydration bukan sekadar bug visual. Penyebab utamanya adalah render awal di server dan state yang tersedia di client tidak berasal dari sumber yang sama atau tidak tersedia pada waktu yang sama. Selama server merender berdasarkan satu asumsi dan browser mengoreksinya setelah mount, tombol login/logout, avatar, dan menu akun akan tampak tidak stabil.

Solusi yang paling aman adalah memilih sumber kebenaran yang jelas untuk auth state, menurunkan state itu dari server bila memungkinkan, dan memakai placeholder netral saat status belum diketahui. Jika data hanya ada di client, tunda render bagian tersebut secara sadar, bukan membiarkan SSR menampilkan state yang salah. Dengan pendekatan ini, Anda tidak hanya menghilangkan hydration mismatch, tetapi juga memperbaiki UX yang sering terasa "aneh" bagi pengguna.