Hydration mismatch pada theme toggle di Nuxt.js biasanya muncul karena server dan client merender nilai awal yang berbeda. Server sering mengirim HTML dengan tema default, misalnya light, tetapi setelah JavaScript berjalan di browser, aplikasi membaca localStorage atau matchMedia lalu langsung mengubah tema menjadi dark. Akibatnya, DOM hasil SSR tidak sama dengan hasil render awal di client, sehingga muncul warning hydration mismatch, flicker, atau kedipan tema saat halaman dimuat.

Perbaikannya bukan sekadar memindahkan logika ke client. Pendekatan yang lebih aman adalah membuat nilai awal tema deterministik di SSR, misalnya dari cookie, lalu memakai useState dengan key stabil agar server dan client membaca state yang sama. Setelah hydration selesai, akses ke window, localStorage, dan matchMedia baru dilakukan di onMounted. Jika ada widget yang memang hanya masuk akal dirender di browser, bungkus dengan <ClientOnly>.

Gejala yang biasanya terlihat

Masalah ini sering terlihat dalam beberapa bentuk berikut:

  • Warning di console seperti hydration completed but contains mismatches.
  • Tampilan tema berkedip dari light ke dark atau sebaliknya saat page load.
  • Ikon toggle berubah setelah hydration, misalnya server menampilkan ikon matahari tetapi client menggantinya menjadi bulan.
  • Kelas CSS di elemen root, seperti html.dark atau body.dark, berubah sesaat setelah halaman tampil.

Kalau aplikasi memakai SSR, gejala ini menandakan bahwa HTML awal dari server tidak konsisten dengan render pertama di browser.

Contoh kode bermasalah

Contoh berikut terlihat sederhana, tetapi rentan menyebabkan hydration mismatch karena nilai awal tema bergantung pada API browser saat setup/render.

<script setup lang="ts">
const theme = ref('light')

if (process.client) {
  const saved = localStorage.getItem('theme')
  if (saved) {
    theme.value = saved
  } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
    theme.value = 'dark'
  }
}
</script>

<template>
  <button @click="theme = theme === 'dark' ? 'light' : 'dark'">
    {{ theme === 'dark' ? '🌙' : '☀️' }}
  </button>
</template>

Ada dua masalah utama pada pola ini:

  1. SSR tidak punya akses ke browser API, sehingga server akan merender tema default, misalnya light.
  2. Client langsung menghitung nilai berbeda sebelum hydration stabil, sehingga markup atau state awal berubah dibanding hasil SSR.

Walaupun ada pengecekan process.client, mismatch tetap bisa terjadi karena server sudah lebih dulu mengirim HTML yang berbeda dari apa yang client ingin render.

Root cause: SSR dan client tidak sepakat tentang nilai awal

Hydration di Vue/Nuxt bekerja dengan mengaitkan event listener dan state ke HTML yang sudah dirender server. Proses ini mengasumsikan bahwa render awal di client menghasilkan struktur dan isi yang sama dengan HTML dari server.

Pada kasus theme toggle, sumber masalahnya hampir selalu satu: nilai awal tema dihitung dari sumber yang tidak tersedia atau tidak sama di server. localStorage dan matchMedia hanya ada di browser. Jika server memakai fallback light tetapi browser segera memilih dark, maka teks, class, atribut, atau ikon bisa berbeda saat hydration.

Jadi tujuan perbaikan adalah memastikan:

  • Server dan client punya nilai awal yang sama.
  • Pembacaan dari browser API dilakukan setelah mount, bukan saat SSR/render awal.
  • Bagian UI yang memang hanya valid di client tidak dipaksa ikut SSR.

Perbaikan utama: jadikan nilai awal tema deterministik

Gunakan cookie sebagai sumber kebenaran awal

Cookie bisa dibaca oleh server saat SSR dan juga oleh client. Ini menjadikannya pilihan praktis untuk menyamakan tema awal. Dibanding localStorage, cookie lebih cocok sebagai initial source of truth untuk preferensi yang memengaruhi HTML hasil SSR.

Berikut composable sederhana untuk mengelola theme state secara konsisten:

// composables/useTheme.ts
export const useTheme = () => {
  const themeCookie = useCookie<'light' | 'dark'>('theme', {
    default: () => 'light'
  })

  const theme = useState<'light' | 'dark'>('theme', () => themeCookie.value || 'light')

  const setTheme = (value: 'light' | 'dark') => {
    theme.value = value
    themeCookie.value = value
  }

  const toggleTheme = () => {
    setTheme(theme.value === 'dark' ? 'light' : 'dark')
  }

  return {
    theme,
    setTheme,
    toggleTheme
  }
}

Poin penting di sini:

  • useCookie('theme') bisa dibaca saat SSR, jadi server tahu tema awal.
  • useState('theme') memakai key stabil, yaitu 'theme'. Ini penting agar state yang sama dipakai lintas render dalam request yang sama dan terhidrasi konsisten di client.
  • Default value dihitung dari cookie, bukan dari browser API.

Catatan: Hindari membuat key useState yang berubah-ubah, misalnya berdasarkan route, waktu, atau nilai acak. Key yang tidak stabil bisa memicu state ganda atau perilaku hydration yang membingungkan.

Terapkan tema ke DOM lewat plugin ringan

Setelah punya state yang konsisten, Anda perlu menerapkannya ke elemen root agar CSS tema aktif. Plugin berikut cukup ringkas dan aman karena sinkron dengan cookie/state.

// plugins/theme.ts
export default defineNuxtPlugin(() => {
  const { theme } = useTheme()

  const applyTheme = (value: 'light' | 'dark') => {
    if (import.meta.client) {
      document.documentElement.classList.toggle('dark', value === 'dark')
      document.documentElement.setAttribute('data-theme', value)
    }
  }

  watchEffect(() => {
    applyTheme(theme.value)
  })
})

Plugin ini tidak membaca localStorage atau matchMedia saat menentukan nilai awal. Ia hanya menerapkan state yang sudah ditentukan sebelumnya.

Komponen toggle yang aman untuk SSR

<script setup lang="ts">
const { theme, toggleTheme } = useTheme()
</script>

<template>
  <button
    type="button"
    :aria-label="theme === 'dark' ? 'Aktifkan mode terang' : 'Aktifkan mode gelap'"
    @click="toggleTheme"
  >
    {{ theme === 'dark' ? '🌙' : '☀️' }}
  </button>
</template>

Karena theme berasal dari cookie yang sama-sama diketahui server dan client, komponen ini tidak lagi bergantung pada pembacaan browser API saat render awal.

Kapan localStorage dan matchMedia tetap boleh dipakai?

localStorage dan matchMedia tetap berguna, tetapi perannya berbeda. Gunakan keduanya untuk sinkronisasi setelah mount, bukan untuk menentukan markup SSR pertama jika Anda ingin menghindari mismatch.

Misalnya, Anda ingin:

  • mengimpor preferensi lama dari localStorage ke cookie,
  • menghormati prefers-color-scheme jika cookie belum ada,
  • menyinkronkan pilihan tema ke browser storage tambahan.

Lakukan itu di onMounted agar hydration lebih aman.

<script setup lang="ts">
const { theme, setTheme } = useTheme()
const themeCookie = useCookie<'light' | 'dark'>('theme')

onMounted(() => {
  const saved = localStorage.getItem('theme') as 'light' | 'dark' | null

  if (!themeCookie.value) {
    if (saved === 'light' || saved === 'dark') {
      setTheme(saved)
    } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      setTheme('dark')
    }
  }

  localStorage.setItem('theme', theme.value)
})

watch(theme, (value) => {
  localStorage.setItem('theme', value)
})
</script>

Pendekatan ini bekerja karena render awal tetap deterministik. Kalau setelah mount ternyata browser punya preferensi lain, perubahan tema terjadi sebagai update state biasa, bukan sebagai konflik antara SSR dan hydration.

Kalau ingin lebih akurat di request pertama: gunakan cookie atau header

Cookie adalah pilihan paling praktis

Jika pengguna pernah memilih tema, simpan ke cookie. Pada request berikutnya, server langsung tahu tema mana yang harus dirender. Ini mengurangi flicker dan hampir selalu menjadi solusi paling sederhana.

Header bisa dipakai bila arsitektur Anda mendukungnya

Dalam beberapa skenario, preferensi awal bisa diturunkan dari header request atau mekanisme lain di sisi server. Intinya tetap sama: server harus punya sinyal yang sama dengan client untuk render pertama. Jika tidak ada sinyal sama sekali, server terpaksa memilih default, dan Anda harus menerima kemungkinan perubahan visual setelah mount.

Trade-off-nya:

  • Cookie: mudah dipakai, cocok untuk preferensi persisten, tetapi perlu dikelola dengan benar agar selalu sinkron.
  • Header: berguna bila preferensi datang dari edge, reverse proxy, atau sistem lain, tetapi implementasinya lebih kontekstual.
  • localStorage saja: mudah di client, tetapi lemah untuk SSR karena tidak tersedia di server.

Gunakan ClientOnly untuk widget yang memang tidak perlu SSR

Tidak semua bagian UI harus ikut SSR. Jika ada widget tema yang sepenuhnya kosmetik atau tergantung browser API kompleks, Anda bisa membungkusnya dengan <ClientOnly>.

<template>
  <ClientOnly>
    <ThemeToggle />
  </ClientOnly>
</template>

Ini menghindari mismatch karena komponen tersebut tidak dirender di server. Namun ada trade-off yang perlu dipahami:

  • Masalah warning hydration pada komponen itu bisa hilang.
  • Tetapi Anda kehilangan SSR untuk bagian tersebut.
  • Jika toggle memengaruhi layout atau konten penting, flicker global tetap bisa terjadi bila tema root belum ditentukan secara deterministik.

Karena itu, ClientOnly sebaiknya dipakai sebagai pelengkap, bukan solusi utama untuk root theme state.

Contoh implementasi ringkas yang lebih aman

Berikut susunan minimal yang praktis:

1. Composable state + cookie

// composables/useTheme.ts
export const useTheme = () => {
  const cookie = useCookie<'light' | 'dark'>('theme', {
    default: () => 'light'
  })

  const theme = useState<'light' | 'dark'>('theme', () => cookie.value || 'light')

  const setTheme = (value: 'light' | 'dark') => {
    theme.value = value
    cookie.value = value
  }

  return {
    theme,
    setTheme,
    toggleTheme: () => setTheme(theme.value === 'dark' ? 'light' : 'dark')
  }
}

2. Plugin untuk menerapkan class tema

// plugins/theme.ts
export default defineNuxtPlugin(() => {
  const { theme } = useTheme()

  if (import.meta.client) {
    watchEffect(() => {
      document.documentElement.classList.toggle('dark', theme.value === 'dark')
    })
  }
})

3. Komponen toggle

<script setup lang="ts">
const { theme, toggleTheme } = useTheme()
</script>

<template>
  <button type="button" @click="toggleTheme">
    Tema: {{ theme }}
  </button>
</template>

4. Sinkronisasi browser API setelah mount bila diperlukan

<script setup lang="ts">
const { setTheme } = useTheme()
const cookie = useCookie<'light' | 'dark'>('theme')

onMounted(() => {
  if (!cookie.value && window.matchMedia('(prefers-color-scheme: dark)').matches) {
    setTheme('dark')
  }
})
</script>

Pola ini menjaga render awal tetap konsisten, tetapi tetap memberi ruang untuk menyesuaikan preferensi browser setelah aplikasi aktif.

Kesalahan umum yang sering memicu hydration mismatch

  • Membaca window atau localStorage langsung di top-level setup untuk menentukan state awal SSR.
  • Menggunakan default state berbeda antara server dan client.
  • Mengubah class root terlalu dini berdasarkan browser API sebelum state SSR selesai terhidrasi.
  • Mengandalkan ClientOnly untuk semuanya sehingga warning hilang, tetapi flicker tema global tetap ada.
  • Memakai key useState yang tidak stabil, misalnya tergantung parameter yang berubah-ubah.
  • Merender teks/ikon berbeda berdasarkan state yang belum stabil, walaupun struktur HTML tampak sama.

Checklist debugging

Kalau mismatch masih muncul, cek beberapa hal ini:

  1. Lihat HTML SSR. Pastikan tema awal yang dirender server sesuai dengan cookie yang dikirim browser.
  2. Periksa nilai cookie pada request pertama. Jika cookie belum ada, tentukan fallback yang jelas dan pahami bahwa perubahan setelah mount mungkin tetap terjadi.
  3. Cari akses browser API di setup/render. Pindahkan ke onMounted jika tidak dibutuhkan untuk SSR.
  4. Periksa class root seperti dark atau data-theme. Apakah server dan client memulai dari nilai yang sama?
  5. Audit komponen turunan. Kadang mismatch bukan di tombol toggle, tetapi di ikon, logo, atau gambar yang bergantung pada tema.
  6. Uji dengan JavaScript lambat atau throttling jaringan untuk melihat apakah ada flicker yang tertutup oleh device cepat.

Checklist manual sebelum rilis

Untuk bug seperti ini, pengujian manual sederhana sangat membantu karena gejalanya visual.

  • Muat ulang halaman dalam kondisi tema light, lalu ulangi untuk dark.
  • Uji halaman pertama yang paling sering diakses, bukan hanya halaman lokal tempat komponen dibuat.
  • Pastikan tidak ada warning hydration di console browser.
  • Uji saat cookie tema sudah ada dan saat cookie belum ada.
  • Uji browser dengan preferensi sistem dark dan light.
  • Uji navigasi antarhalaman untuk memastikan state tetap konsisten.
  • Uji mode production build, bukan hanya development.

Kapan harus memilih tiap pendekatan?

Pilih cookie + useState bila tema memengaruhi SSR

Ini pilihan utama jika tema mengubah class root, warna global, ikon, atau markup yang ikut dirender server.

Pilih onMounted bila hanya butuh sinkronisasi browser

Gunakan untuk membaca localStorage, matchMedia, atau event sistem setelah aplikasi siap di client.

Pilih ClientOnly bila widget benar-benar client-specific

Cocok untuk komponen non-kritis yang tidak perlu hadir di HTML SSR. Jangan jadikan ini pengganti desain state awal yang benar.

Penutup

Bug theme toggle di Nuxt.js hampir selalu berakar pada satu hal: nilai awal tema tidak sama antara SSR dan client. Jika server merender tema default, lalu browser segera membaca localStorage atau matchMedia dan mengganti UI, Anda akan melihat hydration mismatch atau flicker.

Solusi yang paling stabil adalah membuat nilai awal tema deterministik dari cookie atau sinyal request lain, menyimpannya di useState dengan key yang stabil, lalu menunda akses ke API browser hingga onMounted. Untuk bagian yang memang khusus client, gunakan <ClientOnly> secara selektif. Dengan pola ini, warning hydration berkurang, pengalaman visual lebih rapi, dan perilaku tema lebih konsisten di production.