Hydration mismatch di Next.js biasanya terjadi ketika HTML awal yang dirender di server tidak sama dengan hasil render pertama di browser. Pada kasus App Router, sumber masalah yang sangat umum adalah UI awal bergantung pada search params, window.location, atau state yang baru diisi di useEffect.

Gejalanya sering terlihat jelas: muncul warning hydration di console, teks atau atribut berbeda antara server dan client, tombol aktif berubah setelah mount, atau layout terasa “meloncat” karena markup awal diganti setelah React melakukan hydration. Solusi utamanya bukan sekadar “pindahkan ke useEffect”, tetapi memastikan render pertama di server dan render pertama di client menghasilkan output yang stabil.

Kenapa hydration mismatch terjadi?

Pada Next.js, terutama App Router, server dapat merender HTML awal terlebih dahulu. Setelah halaman sampai ke browser, React melakukan hydration: React menempelkan event handler dan merekonsiliasi tree yang sudah ada dengan hasil render client.

Masalah muncul jika server menghasilkan ini:

<button aria-pressed="false">Semua</button>

tetapi render pertama di browser langsung menganggap query string adalah ?tab=promo dan menghasilkan:

<button aria-pressed="true">Promo</button>

Karena struktur, teks, atau atribut berbeda, React akan memberi warning hydration. Dalam beberapa kasus React dapat memperbaiki DOM, tetapi pengalaman pengguna tetap buruk karena ada perubahan visual setelah mount.

Akar masalah yang paling sering

  • Membaca data browser-only saat render awal, misalnya window.location.search, localStorage, atau ukuran viewport.
  • Menginisialisasi state berbeda antara server dan client, misalnya useState(() => new URLSearchParams(window.location.search).get('tab')).
  • Mengubah UI penting di useEffect setelah HTML awal telanjur dirender dengan nilai default yang berbeda.
  • Mengandalkan search params di client padahal server juga perlu tahu nilainya.

Gejala nyata yang perlu dikenali

1. Warning hydration di console

Biasanya berupa pesan bahwa text content atau beberapa atribut tidak cocok antara server dan client.

2. Teks atau atribut berbeda

Contoh umum:

  • Teks filter awal “Semua” lalu berubah menjadi “Promo”.
  • className tombol aktif berubah setelah mount.
  • Atribut seperti aria-selected, aria-pressed, atau disabled berubah.

3. Tombol aktif berpindah setelah mount

UI tab, filter, atau sorting sering terasa benar “sesaat kemudian”, tetapi salah pada render awal. Ini tanda kuat bahwa state awal tidak sinkron dengan data server.

4. Layout meloncat

Jika perbedaan state memengaruhi ukuran elemen, jumlah item, atau visibility panel, pengguna melihat layout shift. Ini bukan hanya warning teknis, tetapi juga masalah UX.

Bedakan data yang aman dihitung saat SSR dan yang harus ditunda ke client

Aman dihitung saat SSR

Data berikut biasanya aman dipakai untuk menentukan UI awal di server:

  • Search params yang diteruskan dari request.
  • Pathname dari request.
  • Data hasil fetch server-side.
  • Cookie atau header yang memang tersedia di request server.

Jika UI awal bergantung pada query seperti ?tab=promo, maka sebaiknya server yang menentukan tab awal, lalu nilainya diteruskan ke client component sebagai props.

Harus ditunda ke client

Data berikut sebaiknya tidak dipakai untuk menentukan markup awal server, kecuali Anda sengaja mengisolasinya:

  • window.location
  • localStorage atau sessionStorage
  • Ukuran viewport dan media query yang hanya diketahui browser
  • State yang baru tersedia setelah effect berjalan

Untuk data semacam ini, pilihan umumnya:

  • Render placeholder yang stabil dulu.
  • Tunda logika ke useEffect tanpa mengubah struktur penting secara drastis.
  • Jika komponennya benar-benar browser-only, pertimbangkan dynamic import dengan ssr: false.

Contoh komponen bermasalah

Berikut pola yang sering memicu mismatch. Komponen client membaca query langsung dari browser saat inisialisasi state.

'use client'

import { useState } from 'react'

export default function ProductTabs() {
  const [tab] = useState(() => {
    const params = new URLSearchParams(window.location.search)
    return params.get('tab') || 'all'
  })

  return (
    <div>
      <button aria-pressed={tab === 'all'}>Semua</button>
      <button aria-pressed={tab === 'promo'}>Promo</button>
    </div>
  )
}

Masalahnya:

  • Server tidak punya window.
  • Kalaupun Anda lindungi dengan pengecekan seperti typeof window !== 'undefined', server dan client tetap bisa menghasilkan state awal yang berbeda.

Versi lain yang tampak “aman” tetapi tetap bermasalah:

'use client'

import { useEffect, useState } from 'react'

export default function ProductTabs() {
  const [tab, setTab] = useState('all')

  useEffect(() => {
    const params = new URLSearchParams(window.location.search)
    setTab(params.get('tab') || 'all')
  }, [])

  return (
    <div>
      <button aria-pressed={tab === 'all'}>Semua</button>
      <button aria-pressed={tab === 'promo'}>Promo</button>
    </div>
  )
}

Ini mungkin tidak selalu memicu warning keras, tetapi tetap menimbulkan UI awal yang salah. Server merender all, lalu setelah mount berubah ke promo. Hasilnya tombol aktif berpindah dan layout bisa meloncat.

Pola perbaikan yang disarankan

1. Baca searchParams di server, lalu kirim initial state yang stabil

Ini pola terbaik jika UI awal memang bergantung pada query string dan Anda ingin hasil SSR benar sejak awal.

Server component page:

import ProductTabs from './ProductTabs'

export default function Page({ searchParams }) {
  const tab = searchParams?.tab === 'promo' ? 'promo' : 'all'

  return <ProductTabs initialTab={tab} />
}

Client component:

'use client'

import { useState } from 'react'

export default function ProductTabs({ initialTab }) {
  const [tab, setTab] = useState(initialTab)

  return (
    <div>
      <button
        aria-pressed={tab === 'all'}
        onClick={() => setTab('all')}
      >
        Semua
      </button>
      <button
        aria-pressed={tab === 'promo'}
        onClick={() => setTab('promo')}
      >
        Promo
      </button>
    </div>
  )
}

Mengapa ini bekerja? Karena server dan client sama-sama memulai dari initialTab yang identik. Render pertama konsisten, sehingga tidak ada mismatch hanya karena query string.

2. Jika nilai browser-only diperlukan, render fallback yang stabil dulu

Misalnya Anda memang harus membaca sesuatu dari browser, seperti preferensi di localStorage atau hash URL. Jangan jadikan nilai itu penentu struktur awal SSR jika tidak tersedia di server.

'use client'

import { useEffect, useState } from 'react'

export default function ClientOnlyNotice() {
  const [mounted, setMounted] = useState(false)
  const [source, setSource] = useState('')

  useEffect(() => {
    setMounted(true)
    const params = new URLSearchParams(window.location.search)
    setSource(params.get('source') || '')
  }, [])

  if (!mounted) {
    return <p>Memuat preferensi...</p>
  }

  return <p>Sumber trafik: {source || 'tidak diketahui'}</p>
}

Trade-off-nya jelas: tidak ada mismatch, tetapi Anda menunda sebagian UI sampai setelah mount. Ini cocok untuk elemen sekunder, bukan konten utama yang penting untuk SEO atau persepsi performa.

3. Validasi dan normalisasi query di server

Jangan teruskan query mentah langsung ke komponen. Normalisasi lebih dulu agar output tetap deterministik.

import ProductTabs from './ProductTabs'

const ALLOWED_TABS = new Set(['all', 'promo'])

export default function Page({ searchParams }) {
  const rawTab = searchParams?.tab
  const initialTab = ALLOWED_TABS.has(rawTab) ? rawTab : 'all'

  return <ProductTabs initialTab={initialTab} />
}

Ini mencegah kasus seperti ?tab=something-else yang bisa membuat state awal menjadi ambigu atau memicu UI yang tidak konsisten.

4. Tunda logika browser-only, tetapi jangan ubah struktur penting secara drastis

Jika Anda harus memakai useEffect, usahakan render awal dan sesudah effect tetap punya struktur yang mirip. Misalnya, hindari mengganti daftar 20 item menjadi 3 item tepat setelah mount hanya karena Anda baru membaca query di client.

Lebih aman jika:

  • Jumlah elemen tetap.
  • Tinggi kontainer diberi ruang minimum.
  • Status loading atau placeholder disiapkan dengan ukuran yang konsisten.

5. Gunakan dynamic import dengan ssr:false bila komponennya benar-benar browser-only

Jika sebuah widget tidak masuk akal untuk dirender di server karena sepenuhnya bergantung pada API browser, Anda bisa mematikannya dari SSR.

import dynamic from 'next/dynamic'

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

export default function Page() {
  return <BrowserWidget />
}

Ini menghindari mismatch karena komponen hanya dirender di client. Namun ada konsekuensinya:

  • Konten tidak hadir di HTML awal.
  • SEO untuk isi komponen itu bisa berkurang.
  • Waktu tampil pertama untuk widget bisa lebih lambat.

Gunakan pola ini jika komponen memang bukan kandidat SSR yang baik, bukan sebagai solusi default untuk semua mismatch.

Contoh perbaikan untuk tombol aktif yang berubah setelah mount

Kasus umum: halaman daftar produk punya filter berdasarkan query ?sort=latest atau ?sort=popular. Versi bermasalah sering seperti ini:

'use client'

import { useEffect, useState } from 'react'

export default function SortButtons() {
  const [sort, setSort] = useState('latest')

  useEffect(() => {
    const params = new URLSearchParams(window.location.search)
    setSort(params.get('sort') || 'latest')
  }, [])

  return (
    <div>
      <button className={sort === 'latest' ? 'active' : ''}>Terbaru</button>
      <button className={sort === 'popular' ? 'active' : ''}>Populer</button>
    </div>
  )
}

Versi yang lebih benar:

import SortButtons from './SortButtons'

export default function Page({ searchParams }) {
  const initialSort = searchParams?.sort === 'popular' ? 'popular' : 'latest'

  return <SortButtons initialSort={initialSort} />
}
'use client'

import { useState } from 'react'

export default function SortButtons({ initialSort }) {
  const [sort, setSort] = useState(initialSort)

  return (
    <div>
      <button
        className={sort === 'latest' ? 'active' : ''}
        aria-pressed={sort === 'latest'}
        onClick={() => setSort('latest')}
      >
        Terbaru
      </button>
      <button
        className={sort === 'popular' ? 'active' : ''}
        aria-pressed={sort === 'popular'}
        onClick={() => setSort('popular')}
      >
        Populer
      </button>
    </div>
  )
}

Dengan pola ini, tombol aktif pada HTML awal sudah sesuai query, sehingga tidak berubah mendadak setelah hydration.

Cara mendiagnosis sumber mismatch dengan cepat

Checklist debugging singkat

  1. Lihat warning di console. Perhatikan apakah yang berbeda adalah text content, atribut, atau struktur elemen.
  2. Cari semua akses browser-only saat render: window, document, location, localStorage.
  3. Periksa inisialisasi useState. Jika nilainya dihitung berbeda di server dan client, itu kandidat utama.
  4. Tinjau useEffect yang mengubah UI penting segera setelah mount. Jika effect langsung mengubah tab aktif, filter, atau teks utama, kemungkinan besar ada ketidaksinkronan SSR-client.
  5. Bandingkan HTML awal dengan hasil setelah mount. Fokus pada class, aria attributes, teks, jumlah item, dan urutan elemen.
  6. Pastikan query yang menentukan UI awal dibaca di server, bukan baru dibaca dari window.location.
  7. Normalisasi search params agar nilai invalid tidak memicu cabang render yang tidak terduga.
  8. Jika komponen memang browser-only, pertimbangkan isolasi dengan ssr: false daripada memaksa SSR yang tidak stabil.

Pertanyaan praktis saat debugging

  • Apakah HTML awal server sudah mewakili state yang benar?
  • Apakah render pertama di client menggunakan nilai awal yang sama persis?
  • Apakah perbedaan hanya kosmetik, atau mengubah struktur DOM?
  • Apakah state dari useEffect seharusnya sebenarnya sudah bisa dihitung dari request?

Kesalahan umum yang sering dilakukan

  • Menganggap useEffect selalu solusi. Effect memang jalan setelah mount, tetapi jika state awal salah, UI tetap berkedip atau meloncat.
  • Memakai typeof window !== 'undefined' di render lalu mengembalikan output berbeda antara server dan client.
  • Menggabungkan sumber kebenaran ganda: query dibaca di server, tetapi client juga menghitung ulang dengan aturan berbeda.
  • Tidak memberi fallback yang stabil untuk komponen browser-only.
  • Mematikan SSR terlalu cepat untuk komponen yang sebenarnya bisa dirender stabil dari data request.

Trade-off UX dan SEO

Membaca search params di server

Kelebihan:

  • HTML awal akurat.
  • Hydration lebih aman.
  • Lebih baik untuk SEO jika konten utama bergantung pada query.
  • Mengurangi flicker dan layout shift.

Kekurangan:

  • Perlu memikirkan aliran data dari server ke client component.
  • Validasi query harus rapi agar state tetap deterministik.

Menunda ke useEffect

Kelebihan:

  • Sederhana untuk data yang memang hanya ada di browser.
  • Cocok untuk elemen sekunder seperti personalisasi ringan.

Kekurangan:

  • Berpotensi menimbulkan perubahan visual setelah mount.
  • Konten awal bisa salah atau kosong.
  • Tidak ideal untuk UI utama yang harus benar sejak SSR.

dynamic import dengan ssr:false

Kelebihan:

  • Menghindari mismatch pada widget browser-only yang kompleks.
  • Mengisolasi area yang tidak cocok untuk SSR.

Kekurangan:

  • Konten tidak ada pada HTML awal.
  • Bisa menurunkan kualitas SEO untuk bagian tersebut.
  • Bisa menunda interaktivitas dan tampilan pertama widget.

Penutup

Untuk debug hydration mismatch di Next.js yang berasal dari URL query dan useEffect, prinsip utamanya sederhana: jangan biarkan server dan client menebak state awal dengan cara berbeda. Jika nilai bisa diketahui dari request, hitung di server dan kirim sebagai props awal. Jika nilai hanya tersedia di browser, tunda dengan fallback yang stabil atau isolasi komponen tersebut dari SSR.

Dengan pola ini, Anda bukan hanya menghilangkan warning hydration, tetapi juga memperbaiki UX: tombol aktif tidak berubah mendadak, teks tidak berkedip, dan layout tidak terasa meloncat setelah mount.