Hydration error di Next.js biasanya bukan masalah React “gagal render”, melainkan render mismatch: HTML yang dikirim server tidak sama dengan hasil render pertama di browser saat proses hydrate. Pada App Router, kasus ini sering terjadi ketika komponen membaca nilai yang hanya tersedia di client, seperti localStorage, preferensi tema dari browser, atau waktu lokal pengguna.
Jika Anda melihat pesan seperti Text content does not match server-rendered HTML atau UI sempat berubah setelah halaman dimuat, akar masalahnya hampir selalu sama: server dan browser menghasilkan markup awal yang berbeda. Solusi yang aman adalah menunda akses browser API ke useEffect, mengirim initial state yang stabil dari server, memakai fallback markup yang konsisten, memisahkan komponen client-only bila perlu, dan hanya memakai suppressHydrationWarning untuk kasus yang benar-benar tidak perlu disamakan.
Kenapa hydration error terjadi di Next.js App Router
Pada SSR, server merender HTML lebih dulu. Setelah HTML sampai ke browser, React melakukan hydration: menghubungkan HTML yang sudah ada dengan tree komponen di client. Proses ini mengasumsikan bahwa render pertama di browser menghasilkan output yang sama dengan output dari server.
Masalah muncul saat komponen merender berdasarkan nilai yang:
- tidak tersedia di server, misalnya
window,document,localStorage,matchMedia; - berubah antar lingkungan, misalnya timezone, locale, atau waktu saat ini;
- berasal dari state yang diinisialisasi berbeda di server dan client.
App Router tidak menghilangkan prinsip ini. Komponen server dan client tetap harus menghasilkan markup awal yang konsisten pada bagian yang di-hydrate.
Gejala umum yang sering terlihat
- Peringatan di console: Hydration failed because the initial UI does not match what was rendered on the server.
- Teks yang berubah setelah load, misalnya jam tampil
10:00lalu menjadi17:00. - Tema halaman berkedip dari terang ke gelap atau sebaliknya.
- Checkbox, toggle, atau label default berbeda antara SSR dan client.
- Class atau atribut seperti
data-themeberubah segera setelah hydrate.
Akar masalah: render mismatch dari nilai client-only
1. Tema gelap/terang dari browser
Kasus umum: server merender tema light, tetapi browser membaca preferensi user dari localStorage atau prefers-color-scheme: dark lalu merender dark. Akibatnya, class, atribut, atau teks yang muncul berbeda.
2. Pembacaan localStorage saat render
localStorage hanya ada di browser. Jika nilai awal state diambil langsung saat render client component, output awal di browser bisa berbeda dari SSR.
3. Nilai berbasis waktu atau timezone
new Date(), Date.now(), dan format waktu lokal dapat menghasilkan nilai berbeda antara server dan browser. Walau selisihnya kecil, itu cukup untuk memicu mismatch jika dipakai langsung dalam markup awal.
Contoh masalah dan perbaikannya
Kasus 1: tema gelap/terang
Before: membaca preferensi tema saat render, sehingga server dan browser bisa menghasilkan markup berbeda.
'use client'
import { useState } from 'react'
export default function ThemeToggle() {
const [theme, setTheme] = useState(
localStorage.getItem('theme') || 'light'
)
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Tema: {theme}
</button>
)
}Kode di atas bermasalah karena localStorage dibaca pada fase render awal komponen client. Di server, nilai itu tidak ada. Di browser, nilainya bisa dark. Hasil awal tidak konsisten.
After: pakai nilai awal yang stabil, lalu sinkronkan setelah mount dengan useEffect.
'use client'
import { useEffect, useState } from 'react'
export default function ThemeToggle() {
const [theme, setTheme] = useState('light')
const [mounted, setMounted] = useState(false)
useEffect(() => {
const saved = window.localStorage.getItem('theme')
if (saved === 'light' || saved === 'dark') {
setTheme(saved)
}
setMounted(true)
}, [])
useEffect(() => {
if (!mounted) return
document.documentElement.dataset.theme = theme
window.localStorage.setItem('theme', theme)
}, [theme, mounted])
if (!mounted) {
return <button disabled>Tema: memuat...</button>
}
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Tema: {theme}
</button>
)
}Mengapa ini aman?
- SSR dan render pertama client sama-sama memakai fallback yang stabil.
- Akses ke
windowdanlocalStorageditunda keuseEffect, yaitu setelah komponen mount di browser. - UI tidak mencoba menampilkan nilai final sebelum nilai browser tersedia.
Trade-off: ada fase sementara memuat.... Ini lebih aman daripada mismatch, tetapi bisa menimbulkan sedikit perubahan UI. Untuk tema global, biasanya lebih baik mengirim state awal dari server jika sumbernya tersedia, misalnya dari cookie.
Kasus 2: kirim initial state yang stabil dari server
Jika preferensi tema disimpan di cookie, server bisa menggunakannya untuk merender markup awal yang benar. Ini mengurangi flicker dan menghindari mismatch.
import { cookies } from 'next/headers'
import ThemeToggle from './ThemeToggle'
export default async function Page() {
const cookieStore = await cookies()
const initialTheme = cookieStore.get('theme')?.value === 'dark' ? 'dark' : 'light'
return <ThemeToggle initialTheme={initialTheme} />
}'use client'
import { useEffect, useState } from 'react'
export default function ThemeToggle({ initialTheme }) {
const [theme, setTheme] = useState(initialTheme)
useEffect(() => {
document.documentElement.dataset.theme = theme
window.localStorage.setItem('theme', theme)
}, [theme])
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Tema: {theme}
</button>
)
}Pola ini lebih baik ketika server punya sumber kebenaran awal. Prinsipnya: jika state awal bisa diketahui di server, kirim dari server. Jangan menebak di client saat render pertama.
Kasus 3: localStorage untuk preferensi UI
Before:
'use client'
import { useState } from 'react'
export default function Sidebar() {
const [collapsed] = useState(localStorage.getItem('sidebar') === 'collapsed')
return <aside>{collapsed ? 'Tertutup' : 'Terbuka'}</aside>
}After:
'use client'
import { useEffect, useState } from 'react'
export default function Sidebar() {
const [collapsed, setCollapsed] = useState(false)
const [ready, setReady] = useState(false)
useEffect(() => {
setCollapsed(window.localStorage.getItem('sidebar') === 'collapsed')
setReady(true)
}, [])
if (!ready) {
return <aside aria-hidden="true">Terbuka</aside>
}
return <aside>{collapsed ? 'Tertutup' : 'Terbuka'}</aside>
}Di sini fallback markup sengaja dibuat konsisten. Jangan ganti struktur DOM besar antara SSR dan client jika tidak perlu, karena makin besar perbedaan, makin sulit debugging.
Kasus 4: waktu dan timezone
Before: merender waktu lokal langsung di JSX.
export default function Clock() {
return <p>{new Date().toLocaleString()}</p>
}Ini rawan mismatch karena server dan browser bisa punya timezone, locale, atau waktu eksekusi berbeda.
After, opsi paling aman: kirim nilai waktu yang stabil dari server, lalu format di client setelah mount bila memang harus mengikuti locale browser.
export default function Page() {
const iso = new Date().toISOString()
return <Clock initialIso={iso} />
}'use client'
import { useEffect, useState } from 'react'
export default function Clock({ initialIso }) {
const [text, setText] = useState(initialIso)
useEffect(() => {
setText(new Date(initialIso).toLocaleString())
}, [initialIso])
return <p>{text}</p>
}Dengan cara ini, SSR dan render pertama client menampilkan string yang sama, yaitu ISO yang stabil. Setelah mount, teks boleh berubah ke format lokal browser.
Alternatif: jika Anda tidak butuh format lokal pengguna, format waktu sepenuhnya di server dan tampilkan hasil akhirnya sebagai string biasa.
Pola perbaikan yang aman
1. Tunda akses browser API ke useEffect
Gunakan useEffect untuk akses window, document, localStorage, sessionStorage, matchMedia, dan API browser lain. Jangan jadikan nilai dari API tersebut sebagai penentu markup awal.
Aturan praktis: jika nilai hanya ada di browser, jangan pakai untuk menentukan HTML pertama yang harus cocok dengan SSR.
2. Kirim initial state yang stabil dari server
Jika sumber data tersedia di request, seperti cookie, header, route params, atau data backend, kirim ke client component sebagai props awal. Ini paling efektif untuk tema, locale yang sudah dipilih user, atau preferensi yang bisa dipersistenkan di server.
3. Pakai fallback markup yang konsisten
Saat nilai final baru diketahui setelah mount, tampilkan fallback yang sama di SSR dan render awal client. Fallback bisa berupa:
- teks netral seperti memuat...;
- placeholder dengan ukuran tetap agar layout tidak lompat;
- state default yang tidak menyesatkan.
Yang penting, fallback tersebut sama di kedua sisi.
4. Pisahkan komponen client-only bila perlu
Jika sebuah widget sepenuhnya bergantung pada browser dan tidak penting untuk SSR, pisahkan menjadi client component yang kecil. Tujuannya bukan “mematikan SSR untuk semua”, tetapi memperkecil area yang berpotensi mismatch.
Contoh kasus yang layak dipisah: pemilih timezone lokal, panel preferensi UI, atau widget yang membaca banyak state dari storage browser.
5. Jaga struktur DOM tetap stabil
Sering kali bug bukan hanya di nilai teks, tetapi pada perubahan struktur seperti jumlah elemen, urutan node, atau atribut penting. Misalnya di server merender <span>, di client berubah menjadi <button>. Hindari perubahan struktur antara SSR dan render awal client kecuali benar-benar perlu.
Kapan suppressHydrationWarning layak dipakai
suppressHydrationWarning bisa dipakai untuk bagian kecil yang memang wajar berbeda antara server dan client, misalnya timestamp yang hanya bersifat informatif dan tidak memengaruhi interaksi.
<time suppressHydrationWarning>
{new Date().toLocaleTimeString()}
</time>Namun fitur ini bukan solusi utama. Ia hanya menyembunyikan peringatan untuk mismatch pada node tertentu.
Layak dipakai jika
- perbedaannya kecil dan terlokalisasi;
- konten memang tidak perlu identik;
- Anda paham sumber mismatch dan sengaja menerimanya.
Sebaiknya dihindari jika
- mismatch berasal dari state penting seperti tema, auth, atau preferensi UI;
- perubahan memengaruhi struktur DOM atau interaksi;
- Anda memakainya untuk menutupi bug yang belum dipahami.
Jika tema salah saat SSR lalu dibetulkan setelah hydrate, pengguna akan melihat flicker. Menambahkan suppressHydrationWarning tidak menghilangkan flicker maupun akar masalahnya.
Kesalahan implementasi yang sering terjadi
Membaca localStorage di inisialisasi state
Contoh ini tampak aman, tetapi tetap bisa memicu perbedaan render awal:
const [value] = useState(() => localStorage.getItem('key') || 'default')Walau lazim dipakai di aplikasi client-only, pada lingkungan SSR/hydration pola ini tetap bermasalah jika output awal bergantung pada hasilnya.
Guard typeof window !== 'undefined' langsung di JSX
Contoh:
{typeof window !== 'undefined' ? 'Client' : 'Server'}Ini justru menjamin output server dan client berbeda. Guard seperti ini boleh dipakai untuk mencegah error akses API browser, tetapi jangan dipakai untuk menghasilkan markup awal yang berbeda.
Mengubah tema hanya setelah mount tanpa state awal server
Ini sering menghindari warning, tetapi tetap menimbulkan flicker dari light ke dark. Jika tema penting untuk tampilan awal, pertimbangkan sumber state yang bisa dibaca server, seperti cookie.
Mencampur data waktu server dan format lokal browser dalam render awal
Misalnya server mengirim UTC, lalu client langsung memformat ulang di render pertama. Lebih aman tampilkan string stabil dulu, lalu ubah setelah mount.
Checklist debugging hydration error
- Cari bagian UI yang berubah setelah load. Biasanya itu sumber mismatch.
- Periksa apakah ada akses browser API saat render. Cari
window,document,localStorage,sessionStorage,matchMedia. - Audit semua pemakaian waktu. Cari
new Date(),Date.now(), dan fungsi format locale. - Bandingkan initial state server vs client. Jika state awal tergantung browser, pindahkan ke
useEffectatau kirim dari server. - Pastikan fallback SSR sama dengan render awal client. Jangan ada teks, class, atau struktur yang berubah sebelum effect jalan.
- Periksa atribut di root atau wrapper. Perubahan class tema,
data-theme, atau atribut layout sering jadi penyebab yang tampak kecil tetapi berdampak besar. - Kecilkan area masalah. Isolasi widget bermasalah ke client component terpisah agar lebih mudah diuji.
- Gunakan suppressHydrationWarning hanya sebagai pengecualian. Bukan langkah pertama.
Pola keputusan: pilih solusi yang mana?
Pakai useEffect jika
- nilai hanya tersedia di browser;
- konten bisa menunggu sampai mount;
- Anda bisa menerima fallback sementara.
Kirim initial state dari server jika
- nilai bisa diketahui saat request, misalnya dari cookie atau backend;
- UI awal harus langsung benar tanpa flicker;
- state tersebut penting untuk layout atau tema global.
Pisahkan client-only component jika
- widget tidak memberi manfaat besar dari SSR;
- ketergantungan browser API sangat dominan;
- Anda ingin membatasi area yang bisa mismatch.
Pakai suppressHydrationWarning jika
- perbedaan hanya pada teks kecil yang tidak penting;
- Anda memahami trade-off dan tidak menutupi bug struktural.
Penutup
Untuk mencegah hydration error di Next.js, fokuslah pada satu prinsip: markup awal dari server harus sama dengan render pertama di browser. Tema, localStorage, dan waktu lokal sering merusak prinsip ini karena nilainya berbeda atau hanya tersedia di client.
Pola yang paling aman adalah menunda akses browser API ke useEffect, mengirim initial state yang stabil dari server saat memungkinkan, dan menyediakan fallback markup yang konsisten. Jika Anda menerapkan tiga hal ini secara disiplin, sebagian besar hydration error pada App Router akan hilang tanpa perlu solusi tambal sulam.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!