Hydration error Next.js terjadi ketika markup HTML yang dikirim server tidak cocok dengan hasil render awal di client. Gejalanya biasanya berupa warning seperti Text content does not match server-rendered HTML, teks jam yang berubah sesaat setelah halaman tampil, tombol yang flicker, atau struktur DOM yang terasa tidak konsisten.

Akar masalahnya hampir selalu sama: ada nilai yang tidak deterministik antara server dan browser. Dalam praktik, sumbernya sering berasal dari Date, format locale/timezone, Math.random(), ID yang dibuat saat render, atau pembacaan state browser seperti window, localStorage, dan ukuran viewport. Solusinya bukan sekadar menyembunyikan warning, tetapi memastikan output render awal tetap konsisten.

Kenapa hydration bisa gagal di Next.js?

Pada SSR, server merender HTML lebih dulu lalu browser melakukan hydration agar HTML statis menjadi komponen React yang interaktif. Proses ini mengasumsikan bahwa render pertama di client menghasilkan output yang sama dengan HTML dari server.

Jika server menghasilkan:

<p>10:00</p>

tetapi render pertama di browser menghasilkan:

<p>17:00</p>

maka React mendeteksi ketidaksesuaian. Akibatnya, muncul warning, sebagian node bisa dirender ulang, UI terlihat berubah mendadak, dan dalam kasus tertentu interaksi awal terasa tidak stabil.

Sumber ketidakcocokan yang paling sering

  • Waktu lokal berbeda: server berjalan di timezone tertentu, browser pengguna di timezone lain.
  • Locale berbeda: format tanggal/jam di server tidak sama dengan locale browser.
  • Nilai acak: Math.random() dipanggil saat render.
  • ID dinamis: ID dibuat ulang pada setiap render dengan cara yang tidak stabil.
  • Browser-only state: render bergantung pada window, document, localStorage, matchMedia, atau ukuran layar.

Gejala nyata hydration error akibat data non-deterministik

1. Teks jam berubah setelah halaman tampil

Ini kasus klasik. Server merender waktu berdasarkan timezone environment server, lalu browser menghitung ulang dengan timezone pengguna.

export default function Clock() {
  return <p>{new Date().toLocaleTimeString()}</p>
}

Kode di atas terlihat sederhana, tetapi bermasalah untuk SSR. Nilai new Date() saat render server hampir pasti berbeda dari render client, bahkan bila selisihnya hanya beberapa milidetik. Ditambah lagi, toLocaleTimeString() dapat menghasilkan format berbeda tergantung locale dan timezone.

2. Tombol atau label flicker

Misalnya label tombol ditentukan dari data di localStorage:

export default function ThemeButton() {
  const theme = localStorage.getItem('theme')
  return <button>Tema: {theme}</button>
}

Di server, localStorage tidak tersedia. Jika dipaksa diakses, aplikasi bisa error. Jika diberi fallback berbeda antara server dan client, markup awal juga bisa tidak konsisten dan menimbulkan flicker.

3. Markup berubah karena kondisi berbasis browser

export default function Menu() {
  const isMobile = window.innerWidth < 768
  return isMobile ? <MobileMenu /> : <DesktopMenu />
}

Server tidak tahu lebar viewport pengguna. Jika server merender DesktopMenu tetapi browser langsung merender MobileMenu, hasil hydration akan bermasalah.

Contoh penyebab dan cara memperbaikinya

Date, locale, dan timezone: jangan hitung ulang secara bebas saat render

Masalah utama pada waktu bukan hanya Date, tetapi kombinasi antara waktu saat ini, locale, dan timezone. Kode berikut rawan mismatch:

export default function PublishedAt() {
  return (
    <time>
      {new Date().toLocaleString('id-ID')}
    </time>
  )
}

Ada beberapa cara memperbaikinya, tergantung kebutuhan.

Pola 1: Serialisasi data dari server

Jika yang ingin ditampilkan adalah waktu dari data backend, kirim nilainya dari server dalam bentuk yang stabil, misalnya string ISO atau timestamp. Lalu gunakan nilai yang sama pada render awal di client.

export default function PublishedAt({ publishedAtIso }) {
  return <time dateTime={publishedAtIso}>{publishedAtIso}</time>
}

Ini deterministik karena server dan client sama-sama menerima string yang identik. Jika perlu format yang lebih ramah pengguna, lakukan dengan strategi yang tetap konsisten.

Pola 2: Gunakan format waktu yang konsisten

Jika Anda ingin tetap memformat tanggal pada server dan client, tentukan locale dan timezone secara eksplisit, jangan bergantung pada default environment.

function formatUtc(isoString) {
  return new Intl.DateTimeFormat('id-ID', {
    dateStyle: 'medium',
    timeStyle: 'short',
    timeZone: 'UTC'
  }).format(new Date(isoString))
}

export default function PublishedAt({ publishedAtIso }) {
  return (
    <time dateTime={publishedAtIso}>
      {formatUtc(publishedAtIso)}
    </time>
  )
}

Pendekatan ini aman bila Anda menerima tampilan dalam timezone tetap, misalnya UTC. Kelemahannya, pengguna tidak melihat waktu lokal mereka.

Pola 3: Render placeholder dulu, nilai lokal diisi setelah mount

Jika kebutuhan produk memang mengharuskan waktu lokal pengguna, jangan render nilai final saat SSR. Render placeholder yang stabil, lalu hitung di client setelah komponen ter-mount.

import { useEffect, useState } from 'react'

export default function LocalTime({ isoString }) {
  const [text, setText] = useState('Memuat waktu...')

  useEffect(() => {
    const formatted = new Intl.DateTimeFormat('id-ID', {
      dateStyle: 'medium',
      timeStyle: 'short'
    }).format(new Date(isoString))

    setText(formatted)
  }, [isoString])

  return <time dateTime={isoString}>{text}</time>
}

Kenapa ini bekerja? Karena render server dan render awal client sama-sama menghasilkan teks Memuat waktu.... Nilai lokal baru diisi setelah hydration selesai, sehingga tidak memicu mismatch.

Catatan: placeholder lebih baik daripada langsung merender nilai yang berpotensi berbeda. Untuk UX, Anda bisa memakai skeleton atau ruang tetap agar layout tidak bergeser.

Math.random dan ID dinamis: jangan dibuat saat render awal

Kode seperti ini hampir pasti menyebabkan output berbeda:

export default function PromoBox() {
  const id = Math.random().toString(36).slice(2)
  return <div id={id}>Promo</div>
}

Server dan client akan membuat ID yang berbeda. Masalah serupa muncul bila class name, key, atau atribut lain dibentuk dari angka acak saat render.

Perbaikannya:

  • Gunakan ID dari data backend bila tersedia.
  • Buat ID di server lalu kirim sebagai props.
  • Jika ID hanya dibutuhkan setelah mount untuk integrasi browser/plugin, buat di useEffect atau mekanisme lain yang tidak memengaruhi SSR awal.
import { useEffect, useState } from 'react'

export default function ClientOnlyIdBox() {
  const [id, setId] = useState(null)

  useEffect(() => {
    setId(`promo-${crypto.randomUUID()}`)
  }, [])

  return <div id={id ?? undefined}>Promo</div>
}

Pola ini aman bila elemen tidak bergantung pada ID tersebut untuk menghasilkan markup SSR yang harus identik.

Browser-only state: baca di client, bukan saat SSR

Kasus umum lain adalah tema, preferensi user, media query, viewport, atau informasi perangkat. Hindari membaca API browser langsung di body komponen saat SSR.

Contoh bermasalah:

export default function ThemeLabel() {
  const theme = window.localStorage.getItem('theme') || 'light'
  return <span>{theme}</span>
}

Perbaikannya adalah memisahkan nilai default SSR yang stabil dari nilai aktual client.

import { useEffect, useState } from 'react'

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

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

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

Konsekuensinya, pengguna mungkin melihat nilai default sesaat sebelum nilai aktual diterapkan. Untuk mengurangi flicker, Anda bisa:

  • menerapkan theme lebih awal lewat script inline yang sangat kecil,
  • menggunakan kelas CSS default yang netral,
  • atau menunda render bagian tertentu sampai state client tersedia.

Dynamic import dengan ssr: false: gunakan bila komponen memang client-only

Ada komponen yang secara alami tidak cocok dirender di server, misalnya widget yang sepenuhnya bergantung pada API browser, library visualisasi yang membaca DOM, atau komponen yang menampilkan waktu lokal real-time.

Dalam situasi seperti itu, nonaktifkan SSR untuk komponen tersebut saja.

import dynamic from 'next/dynamic'

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

export default function Header() {
  return (
    <header>
      <h2>Dashboard</h2>
      <ClientClock />
    </header>
  )
}

Pola ini tepat jika isi komponen tidak penting untuk HTML awal, SEO, atau performa render pertama. Namun jangan gunakan ssr: false sebagai solusi instan untuk semua hydration error. Jika terlalu banyak bagian dipindahkan ke client-only, Anda kehilangan manfaat SSR.

Pola perbaikan yang paling aman

1. Buat render awal deterministik

Prinsip utamanya sederhana: server dan client harus menghasilkan output pertama yang sama. Beberapa cara praktis:

  • Kirim data yang sudah ditentukan server dalam props atau payload ter-serialisasi.
  • Hindari memanggil sumber nilai dinamis saat render awal.
  • Tentukan locale/timezone secara eksplisit bila formatting dilakukan di dua sisi.
  • Gunakan placeholder untuk nilai yang hanya valid di browser.

2. Pisahkan data bisnis dari format tampilan

Simpan dan kirim data mentah seperti ISO timestamp, angka, atau string ID yang stabil. Format untuk tampilan dilakukan dengan aturan yang jelas. Ini memudahkan debugging karena Anda bisa membedakan apakah masalah ada di data atau di presentasi.

3. Gunakan hybrid rendering secara sadar

Tidak semua bagian halaman harus mengikuti strategi yang sama. Bagian yang penting untuk SEO dan cepat tampil bisa dirender di server. Bagian yang murni personalisasi browser bisa dirender setelah mount atau dijadikan client-only component.

Checklist debugging hydration error Next.js

Saat warning hydration muncul, jangan langsung menebak-nebak. Gunakan checklist berikut:

  1. Periksa teks yang berubah: apakah tanggal, jam, angka acak, atau label tema berubah setelah mount?
  2. Cari pemanggilan non-deterministik: new Date(), Date.now(), Math.random(), generator ID, atau API serupa di dalam render.
  3. Cek format locale/timezone: apakah server dan client memakai default locale atau timezone yang berbeda?
  4. Audit akses browser-only API: window, document, localStorage, matchMedia, navigator.
  5. Bandingkan output server vs client: lihat HTML awal dan state setelah hydration di browser.
  6. Periksa conditional rendering: apakah server dan client memilih cabang JSX yang berbeda?
  7. Review komponen pihak ketiga: beberapa library membaca DOM saat render dan tidak aman untuk SSR.
  8. Uji dengan timezone berbeda: jalankan pengujian pada environment lokal dan browser dengan locale/timezone yang berbeda untuk memancing mismatch.

Tanda bahwa akar masalah ada pada waktu lokal

  • Warning hanya muncul pada field tanggal/jam.
  • Teks berubah beberapa saat setelah halaman tampil.
  • Bug tidak selalu reproducible di mesin developer yang timezone-nya sama dengan server.
  • Masalah muncul lebih sering pada pengguna lintas negara atau region.

Trade-off SEO dan UX dari setiap pendekatan

SSR penuh dengan format final

Kelebihan: HTML lengkap tersedia sejak awal, baik untuk konten yang perlu segera tampil dan mudah diindeks.

Kekurangan: rentan mismatch jika format bergantung pada timezone atau state browser.

Placeholder lalu isi di client

Kelebihan: aman dari hydration mismatch untuk data client-only, implementasinya jelas.

Kekurangan: ada jeda sebelum nilai final tampil. Jika placeholder tidak dirancang baik, UX bisa terasa kurang halus.

Client-only dengan ssr: false

Kelebihan: sederhana untuk komponen yang memang tidak relevan di server.

Kekurangan: konten tidak ada di HTML awal; bisa berdampak pada SEO, performa persepsi, dan konsistensi loading.

Format waktu konsisten dengan timezone tetap

Kelebihan: deterministik dan cocok untuk data audit, log, atau aplikasi internal.

Kekurangan: kurang personal karena pengguna tidak melihat waktu lokal mereka.

Kapan memilih SSR, CSR, atau hybrid?

Pilih SSR bila:

  • konten penting untuk SEO atau harus terlihat cepat,
  • data bisa dibuat deterministik di server,
  • personalization tidak dominan pada render awal.

Pilih CSR/client-only bila:

  • komponen sepenuhnya bergantung pada API browser,
  • isi komponen tidak penting untuk indeks mesin pencari,
  • nilai utama memang baru tersedia setelah aplikasi berjalan di browser.

Pilih hybrid bila:

  • kerangka halaman perlu SSR, tetapi sebagian isi bergantung pada user environment,
  • Anda ingin menjaga SEO sambil tetap menampilkan data lokal atau personalisasi setelah mount,
  • halaman memiliki campuran konten statis, data server, dan state client-only.

Dalam banyak aplikasi Next.js, hybrid adalah pilihan paling realistis: SSR untuk struktur dan data utama, lalu CSR terbatas untuk elemen yang memang tidak bisa ditentukan secara aman di server.

Kesalahan umum yang sering terjadi

  • Memanggil new Date() langsung di JSX untuk menampilkan jam saat ini.
  • Mengandalkan locale default environment tanpa menetapkan timezone.
  • Membuat ID unik dengan Math.random() saat render.
  • Mengakses window atau localStorage sebelum komponen mount.
  • Menggunakan ssr: false terlalu luas hanya untuk menutupi mismatch.
  • Menganggap warning hydration aman diabaikan, padahal gejalanya bisa memicu flicker dan perilaku UI yang sulit diprediksi.

Ringkasan praktis

Jika Anda menemui hydration error Next.js akibat waktu lokal dan data non-deterministik, fokuslah pada satu prinsip: render awal harus identik antara server dan client. Untuk data waktu, kirim nilai yang stabil dari server, tetapkan locale/timezone secara eksplisit jika perlu, atau render placeholder lalu format di client bila memang harus mengikuti timezone pengguna. Untuk state browser-only, baca nilainya di useEffect atau jadikan komponen client-only bila benar-benar tidak cocok untuk SSR.

Dengan pendekatan ini, Anda tidak hanya menghilangkan warning hydration, tetapi juga mencegah teks jam berubah mendadak, tombol flicker, dan markup yang tidak konsisten. Hasilnya adalah UI yang lebih stabil, debugging yang lebih mudah, dan keputusan SSR/CSR yang lebih terukur.