Hydration mismatch di Next.js terjadi ketika HTML hasil render di server tidak sama dengan hasil render pertama di browser. Efeknya bisa berupa warning di console, UI yang berubah setelah load, event handler yang tidak terpasang sesuai ekspektasi, atau komponen yang terasa “berkedip” saat halaman dibuka.
Masalah ini tidak selesai dengan jawaban generik seperti “coba pakai useEffect” atau “jadikan client component”. Pendekatan yang benar adalah debug berbasis bukti: bandingkan output server dan client, identifikasi sumber nilai yang tidak stabil, lalu perbaiki kontrak render pertama agar deterministik. Itu inti dari semangat “Stop Telling Me to Ask an LLM”: jangan menebak, kumpulkan bukti.
Apa yang sebenarnya terjadi saat hydration
Pada SSR, server mengirim HTML awal ke browser. Setelah JavaScript dimuat, React melakukan hydration, yaitu mengaitkan tree React di client dengan HTML yang sudah ada. Agar proses ini mulus, render pertama di client harus menghasilkan markup yang setara dengan markup dari server.
Jika ada perbedaan pada teks, atribut, struktur elemen, atau urutan node, React dapat memunculkan warning hydration mismatch. Dalam kasus tertentu React masih bisa memperbaiki sebagian DOM, tetapi itu bukan perilaku yang sebaiknya diandalkan karena bisa menimbulkan bug yang sulit direproduksi.
Gejala yang perlu dikenali
Warning di console tentang text content atau markup yang tidak cocok.
Konten berubah sesaat setelah halaman tampil.
Nilai tanggal, angka, atau locale berbeda antara server dan browser.
Komponen hanya error pada deploy/production, tetapi tidak selalu muncul di lokal.
Bug muncul hanya untuk zona waktu, bahasa, atau browser tertentu.
Kalau gejalanya intermiten, biasanya ada input yang tidak stabil: waktu saat ini, nilai acak, data async yang berubah, atau cabang render yang bergantung pada API browser.
Workflow debug: isolasi dulu, baru perbaiki
1. Reproduksi dengan kondisi yang konsisten
Jangan mulai dari asumsi. Buat masalahnya bisa direproduksi.
Catat route dan komponen yang memunculkan mismatch.
Uji tanpa ekstensi browser jika perlu.
Coba hard refresh untuk memastikan SSR benar-benar terjadi.
Jika data berasal dari API, cek apakah respons berubah antar request.
2. Cari node pertama yang berbeda
Hydration mismatch sering terlihat di node yang terkena gejala, tetapi sumbernya bisa lebih atas. Fokus pada elemen pertama yang berbeda antara HTML server dan render awal client.
Pertanyaan yang perlu dijawab:
Apakah teksnya berubah?
Apakah struktur elemen berubah, misalnya div muncul hanya di client?
Apakah atribut seperti className, checked, disabled, atau value berbeda?
Apakah list dirender dengan data atau key yang tidak stabil?
3. Log input render, bukan hanya error akhir
Jangan hanya membaca warning. Log semua input yang mempengaruhi render pertama.
function PriceLabel({ amount, locale }) {
const formatted = new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'IDR'
}).format(amount);
if (typeof window === 'undefined') {
console.log('[SSR] PriceLabel', { amount, locale, formatted });
} else {
console.log('[Client first render] PriceLabel', { amount, locale, formatted });
}
return <span>{formatted}</span>;
}Dengan pola ini, Anda tidak menebak apakah masalahnya ada di locale, nilai amount, atau format output. Anda melihat perbedaannya langsung.
4. Bekukan komponen menjadi versi minimal
Kalau komponen besar, kurangi variabelnya. Hapus styling dinamis, data async sekunder, dan nested component satu per satu sampai mismatch hilang. Tujuannya bukan “memperbaiki cepat”, tetapi menemukan pemicu minimal.
5. Verifikasi setelah perbaikan
Setelah akar masalah ditemukan, jangan berhenti di “warning hilang”. Verifikasi bahwa:
HTML awal server dan render pertama client konsisten.
Tidak ada flicker baru setelah hydration.
Data tetap benar saat navigasi, refresh, dan kondisi locale/timezone berbeda.
Pola root cause yang paling sering
1. State awal berbeda antara server dan client
Ini salah satu penyebab paling umum. Server merender state awal A, tetapi client langsung merender state awal B sebelum efek berjalan.
Before:
import { useState } from 'react';
export default function Sidebar() {
const [open] = useState(() => window.innerWidth > 1024);
return (
<aside data-open={open}>
{open ? 'Expanded' : 'Collapsed'}
</aside>
);
}Masalahnya jelas: saat SSR tidak ada window. Bahkan jika Anda mengakalinya dengan pengecekan, render pertama server dan client bisa tetap berbeda.
After:
import { useEffect, useState } from 'react';
export default function Sidebar() {
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(window.innerWidth > 1024);
}, []);
return (
<aside data-open={open}>
{open ? 'Expanded' : 'Collapsed'}
</aside>
);
}Mengapa ini bekerja? Karena render pertama di server dan client sama-sama menggunakan false. Penyesuaian berdasarkan kondisi browser dilakukan setelah hydration, bukan saat render awal.
Trade-off: ada kemungkinan perubahan UI sesaat setelah mount. Jika flicker tidak bisa diterima, pertimbangkan CSS responsif atau client-only boundary untuk bagian yang memang bergantung penuh pada browser.
2. Akses browser-only API saat SSR
API seperti window, document, localStorage, matchMedia, dan navigator tidak tersedia di server. Bahkan ketika dibungkus typeof window !== 'undefined', hasil render masih bisa berbeda bila nilai dari API tersebut mempengaruhi markup awal.
Before:
export default function ThemeBanner() {
const theme = typeof window !== 'undefined'
? localStorage.getItem('theme')
: 'light';
return <div>Tema aktif: {theme}</div>;
}Server akan merender light, tetapi client bisa langsung merender dark. Itu mismatch.
After:
import { useEffect, useState } from 'react';
export default function ThemeBanner() {
const [theme, setTheme] = useState('light');
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved) setTheme(saved);
}, []);
return <div>Tema aktif: {theme}</div>;
}Jika bagian UI sepenuhnya tidak bermakna tanpa API browser, Anda bisa memindahkannya ke komponen client-only menggunakan dynamic import dengan SSR dimatikan.
import dynamic from 'next/dynamic';
const ThemeBannerClient = dynamic(() => import('./ThemeBannerClient'), {
ssr: false,
});
export default function Page() {
return <ThemeBannerClient />;
}Kapan dipakai: gunakan strategi ini bila komponen memang bergantung penuh pada browser, misalnya integrasi editor WYSIWYG, peta interaktif, atau widget yang membaca DOM/browser state. Jangan jadikan ini solusi default untuk semua mismatch karena Anda kehilangan manfaat SSR pada bagian tersebut.
3. Waktu, timezone, dan locale
Format tanggal dan angka sering berbeda antara environment server dan browser. Perbedaan timezone, locale default, atau waktu eksekusi beberapa milidetik saja bisa mengubah output.
Before:
export default function HeaderTime() {
return <p>{new Date().toLocaleString()}</p>;
}Server dan client hampir pasti tidak mengevaluasi ini pada waktu yang identik. Locale default juga belum tentu sama.
After, opsi paling aman untuk SSR:
export default function HeaderTime({ isoString }) {
return <p>{isoString}</p>;
}Server menghitung nilai sekali, lalu client menerima nilai yang sama sebagai prop untuk render pertama.
After, jika perlu format lokal di browser:
import { useEffect, useState } from 'react';
export default function HeaderTime({ isoString }) {
const [formatted, setFormatted] = useState(isoString);
useEffect(() => {
setFormatted(new Date(isoString).toLocaleString());
}, [isoString]);
return <p>{formatted}</p>;
}Render pertama tetap stabil menggunakan isoString. Setelah hydration, client boleh memformat ulang sesuai locale pengguna.
Catatan: untuk tampilan tanggal/angka yang harus identik antara server dan client, kirim locale dan timezone secara eksplisit dari sumber yang sama. Jangan bergantung pada default environment.
4. Random value atau ID yang tidak stabil
Semua nilai acak yang dipanggil saat render berpotensi memicu mismatch: Math.random(), token sementara, atau ID yang dibuat manual.
Before:
export default function Promo() {
const variant = Math.random() > 0.5 ? 'A' : 'B';
return <section>Variant {variant}</section>;
}Server mungkin memilih A, client memilih B.
After:
export default function Promo({ variant }) {
return <section>Variant {variant}</section>;
}Nilai acak harus diputuskan di satu sisi saja, lalu diteruskan sebagai data tetap untuk render pertama. Jika yang Anda butuhkan adalah ID untuk asosiasi elemen dan bukan untuk logika bisnis, gunakan mekanisme ID yang stabil dari React, bukan generator acak di dalam render.
5. Conditional render yang bergantung pada environment
Pola seperti ini terlihat aman, tetapi sering menjadi sumber mismatch:
{typeof window !== 'undefined' ? <ClientMenu /> : null}Server merender null, client merender node baru pada render pertama. Kalau bagian ini berada di tengah struktur DOM yang sensitif, mismatch mudah terjadi.
Lebih aman menggunakan salah satu dari tiga strategi berikut:
Samakan output awal, lalu ubah setelah mount.
Client-only boundary bila bagian itu memang tidak cocok untuk SSR.
Refactor logika agar keputusan tidak bergantung pada environment saat render pertama.
Contoh perbaikan:
import { useEffect, useState } from 'react';
export default function PageTools() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<div>
<h2>Tools</h2>
{mounted ? <ClientMenu /> : <div aria-hidden="true" />}
</div>
);
}Di sini output awal lebih terkontrol. Anda sengaja menunda bagian client-only sampai setelah mount.
6. Data async berubah antara server dan client
Masalah ini sering terjadi saat server merender dari data request pertama, tetapi client langsung mem-fetch ulang dan mendapat data yang sudah berubah. Hasilnya, render pertama client berbeda dari HTML awal.
Pola bermasalah:
Server mengambil data A, client saat mount mengambil data B dan langsung merender B.
Data bergantung pada header, cookie, auth state, atau waktu request yang berbeda.
List menggunakan key yang berubah antar request.
Strategi perbaikan:
Pastikan client memulai dari data yang sama dengan SSR.
Jika revalidasi dilakukan di client, lakukan setelah hydration dengan state awal yang identik.
Gunakan identifier yang stabil untuk key, bukan index atau nilai acak.
Contoh prinsip:
import { useEffect, useState } from 'react';
export default function ProductList({ initialProducts }) {
const [products, setProducts] = useState(initialProducts);
useEffect(() => {
let cancelled = false;
async function refresh() {
const res = await fetch('/api/products');
const next = await res.json();
if (!cancelled) setProducts(next);
}
refresh();
return () => {
cancelled = true;
};
}, []);
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}Mengapa ini aman? Karena render pertama menggunakan initialProducts yang sama dengan SSR. Re-fetch dilakukan setelah hydration.
Strategi logging yang benar-benar membantu
Log input determinan render
Jangan log seluruh objek besar tanpa tujuan. Log nilai yang benar-benar menentukan markup:
props utama
state awal
hasil formatting tanggal/angka
flag conditional render
sumber environment seperti locale, timezone, atau keberadaan browser API
function DebuggableGreeting({ user, locale }) {
const isBrowser = typeof window !== 'undefined';
const name = user?.name ?? 'Guest';
const text = new Intl.DateTimeFormat(locale, {
dateStyle: 'medium'
}).format(new Date(user.lastLogin));
const snapshot = { isBrowser, locale, name, text };
console.log(isBrowser ? '[Client]' : '[SSR]', snapshot);
return <p>Halo {name}, login terakhir {text}</p>;
}Tambahkan penanda komponen
Pada tree yang besar, sulit tahu log berasal dari mana. Tambahkan nama komponen dan ID entitas yang sedang dirender. Tujuannya agar satu warning bisa ditelusuri ke render tertentu.
Bandingkan hasil serialisasi
Untuk data props yang kompleks, serialisasikan subset yang relevan. Ini membantu menemukan perubahan halus seperti undefined vs null, locale berbeda, atau urutan field/list yang berubah.
Kapan memakai dynamic import atau client-only boundary
Jangan buru-buru mematikan SSR hanya karena ada hydration mismatch. Gunakan dynamic import dengan SSR nonaktif atau boundary client-only bila memenuhi salah satu kondisi berikut:
Komponen memang bergantung penuh pada browser API.
Komponen pihak ketiga melakukan akses DOM saat render.
Nilai awal tidak mungkin dibuat identik di server tanpa kompleksitas yang tidak sepadan.
SEO dan waktu tampil konten untuk komponen itu tidak kritis.
Trade-off:
Keuntungan: menghindari mismatch dan menyederhanakan integrasi komponen browser-only.
Kerugian: bagian tersebut tidak ikut SSR, bisa menambah loading state, dan dapat mengurangi performa persepsi atau SEO untuk konten yang penting.
Gunakan pendekatan ini secara terukur, bukan sebagai solusi sapu jagat.
Checklist debug hydration mismatch di Next.js
Apakah render pertama server dan client memakai data awal yang sama?
Apakah ada akses ke window, document, localStorage, matchMedia, navigator saat render?
Apakah ada new Date(), toLocaleString(), atau formatting yang bergantung pada timezone/locale default?
Apakah ada Math.random() atau ID acak saat render?
Apakah ada conditional render berbasis typeof window atau environment lain?
Apakah data async di client langsung menggantikan data SSR sebelum hydration selesai?
Apakah list memakai key yang stabil?
Apakah Anda sudah log input render di SSR dan client, bukan hanya output akhir?
Apakah masalah hilang ketika komponen diperkecil menjadi versi minimal?
Jika memilih client-only boundary, apakah keputusan itu memang layak dari sisi SSR, SEO, dan UX?
Contoh workflow tim yang bisa dipakai berulang
1. Buka warning dan petakan ke komponen
Jangan berhenti di stack trace. Cari komponen pertama yang menghasilkan teks/markup berbeda.
2. Tulis hipotesis berbasis kategori root cause
Gunakan kategori yang jelas: state awal, browser API, waktu/locale, random, conditional render, atau data async.
3. Tambahkan logging SSR vs client
Log input render di komponen target dan parent yang relevan.
4. Bekukan data
Jika perlu, hardcode data sementara untuk membuktikan apakah mismatch hilang. Ini membantu membedakan bug pada data pipeline vs bug pada render logic.
5. Terapkan perbaikan minimal
Prioritaskan solusi yang menjaga SSR tetap valid: samakan state awal, kirim nilai deterministik dari server, atau tunda pembacaan browser API sampai mount.
6. Verifikasi lintas kondisi
Uji refresh penuh, navigasi client, locale berbeda, dan data yang berubah cepat. Tujuannya memastikan Anda memperbaiki akar masalah, bukan hanya satu gejala.
Kesalahan umum yang sering memperpanjang debugging
Mengganti banyak hal sekaligus sehingga akar masalah tidak pernah terbukti.
Langsung menjadikan komponen client-only tanpa memahami penyebab mismatch.
Menganggap pengecekan
typeof window !== 'undefined'otomatis aman untuk SSR.Tidak membedakan antara render pertama dan update setelah mount.
Melupakan bahwa formatting locale dapat berbeda walau data mentah sama.
Penutup
Debug SSR tanpa tebak AI berarti memperlakukan hydration mismatch sebagai masalah determinisme render, bukan misteri. Di Next.js, mayoritas kasus bisa dijelaskan oleh satu pola sederhana: server dan client tidak memulai dari input yang sama.
Kalau tim Anda punya workflow yang disiplin—reproduksi, isolasi node yang berbeda, log input render, kelompokkan root cause, lalu verifikasi hasil—warning hydration tidak lagi jadi bug yang “aneh”. Ia menjadi masalah engineering yang bisa dibuktikan, diperbaiki, dan dicegah.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!