Render mismatch di Next.js App Router biasanya terjadi ketika HTML hasil render di server tidak cocok dengan hasil render pertama di client saat hydration. Akibatnya, Anda bisa melihat warning hydration, UI berkedip, teks berubah setelah mount, atau komponen yang tampil berbeda antara server dan browser.
Kasus paling umum adalah komponen yang membaca localStorage, window, waktu saat ini, nilai acak, atau media query langsung pada render awal. Solusinya bukan sekadar “pindahkan ke client”, tetapi memastikan output render pertama stabil dan konsisten antara server dan client, lalu memindahkan hal-hal yang hanya tersedia di browser ke fase setelah mount atau ke boundary client-only yang tepat.
Memahami akar masalah: SSR lalu hydration di App Router
Untuk debug yang efektif, Anda perlu memahami alurnya:
- Server render: Next.js menghasilkan HTML awal di server.
- Browser menerima HTML: pengguna melihat markup awal ini sebelum JavaScript aplikasi selesai aktif.
- Hydration: React di browser menghubungkan event handler dan state ke HTML yang sudah ada.
- Mismatch bila output berbeda: jika render pertama di client menghasilkan struktur/teks/atribut yang berbeda dari HTML server, React akan mengeluarkan warning dan bisa mengganti sebagian UI.
Di App Router, pemisahan Server Component dan Client Component membantu, tetapi tidak otomatis mencegah mismatch. Komponen dengan 'use client' tetap bisa dirender lebih dulu dengan asumsi awal yang berbeda, lalu berubah setelah browser membaca state sebenarnya.
Catatan: hydration issue bukan selalu bug React. Sering kali ini adalah konsekuensi dari data atau state awal yang tidak deterministik antara server dan client.
Gejala nyata render mismatch dari state client
Masalah ini biasanya muncul dalam bentuk berikut:
- Warning hydration di console, misalnya teks atau atribut tidak cocok.
- UI flicker, contohnya tema awal terang lalu berubah ke gelap setelah mount.
- Konten berubah setelah mount, misalnya label login, jumlah item, atau preferensi layout.
- Perbedaan struktur DOM, misalnya server merender satu elemen, client merender elemen berbeda.
- Bug interaksi halus, seperti class CSS atau atribut ARIA berbeda saat pertama kali hydrate.
Sumbernya sering berasal dari pembacaan API browser atau nilai yang berubah-ubah:
localStorageatausessionStoragewindow,document,navigator- waktu saat ini:
Date.now(),new Date() - nilai acak:
Math.random() - media query: preferensi dark mode, ukuran viewport, reduced motion
Contoh bug minimal yang sering terjadi
Membaca localStorage saat inisialisasi state
Ini contoh yang kelihatannya wajar, tetapi sering memicu mismatch atau flicker:
'use client'
import { useState } from 'react'
export default function ThemeLabel() {
const [theme] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('theme') || 'light'
}
return 'light'
})
return <p>Tema aktif: {theme}</p>
}
Apa yang terjadi?
- Di server,
windowtidak ada, jadi hasilnyalight. - Di client, render awal bisa membaca
localStoragedan menghasilkandark. - HTML server menampilkan
light, tetapi React saat hydrate mengharapkandark.
Hasilnya bisa berupa warning hydration atau perubahan teks sesaat setelah mount.
Menggunakan waktu atau nilai acak langsung di render
export default function PromoId() {
const id = Math.random().toString(36).slice(2, 8)
return <span>Kode: {id}</span>
}
Nilai acak di server hampir pasti berbeda dari client. Hal yang sama berlaku untuk:
export default function Clock() {
return <time>{new Date().toLocaleTimeString()}</time>
}
Walau perbedaannya kecil, output bisa berubah dalam hitungan detik antara server dan client.
Media query pada render awal
'use client'
export default function SidebarHint() {
const isMobile = window.matchMedia('(max-width: 768px)').matches
return <p>{isMobile ? 'Mode mobile' : 'Mode desktop'}</p>
}
Ini lebih buruk lagi karena akses window langsung saat render akan gagal di server. Bahkan jika diberi guard, hasil server dan client tetap bisa berbeda.
Langkah investigasi: cara menemukan sumber mismatch
1. Mulai dari warning hydration di console
Jangan abaikan warning yang muncul saat development. Pesan ini sering memberi petunjuk apakah masalahnya ada pada:
- teks yang tidak cocok
- atribut yang berbeda
- struktur elemen yang berubah
- komponen tertentu dalam stack trace
Fokus ke komponen yang menghasilkan data dinamis pada render awal.
2. Audit semua akses browser-only di fase render
Cari pemakaian berikut di dalam body komponen atau inisialisasi state:
window,document,navigatorlocalStorage,sessionStoragematchMediaDate.now(),new Date()Math.random()
Kalau nilai-nilai ini memengaruhi output JSX pertama, besar kemungkinan itu penyebab mismatch.
3. Bandingkan output server vs render pertama di client
Pertanyaan yang perlu diajukan:
- Apa yang dirender server untuk state awal?
- Apa yang dirender browser sebelum
useEffectberjalan? - Apakah teks, class, atribut, atau struktur elemen bisa berbeda?
Intinya, hydration membandingkan HTML server dengan hasil render client sebelum efek setelah mount dijalankan. Jika sudah beda pada tahap ini, masalah pasti muncul.
4. Isolasi komponen yang dicurigai
Jika halaman kompleks, pecah komponen satu per satu. Nonaktifkan bagian yang menggunakan state client sampai warning hilang. Ini sering lebih cepat daripada menebak-nebak dari seluruh tree.
5. Periksa boundary Server dan Client Component
Di App Router, tentukan dengan jelas:
- Data yang bisa disiapkan stabil di server tetap di Server Component
- Interaksi atau API browser pindah ke Client Component
Namun ingat, memindahkan komponen ke client bukan berarti mismatch selesai. Jika render pertama di client memakai state yang tidak sama dengan output server, gejalanya tetap ada.
Pola perbaikan yang paling aman
1. Gunakan initial state yang stabil, lalu sinkronkan di useEffect
Ini pola paling umum untuk localStorage, tema, preferensi UI, dan media query.
'use client'
import { useEffect, useState } from 'react'
export default function ThemeLabel() {
const [theme, setTheme] = useState('light')
useEffect(() => {
const saved = localStorage.getItem('theme')
if (saved === 'light' || saved === 'dark') {
setTheme(saved)
}
}, [])
return <p>Tema aktif: {theme}</p>
}
Mengapa ini bekerja?
- Server merender
light. - Render pertama di client juga
light. - Setelah hydration selesai,
useEffectmembacalocalStoragedan mengubah state bila perlu.
Trade-off-nya adalah UI bisa berubah setelah mount. Ini menghilangkan mismatch, tetapi mungkin masih menimbulkan flicker jika default awal berbeda dari preferensi pengguna.
2. Tambahkan guard mounted untuk konten yang benar-benar bergantung pada browser
Jika komponen tidak bermakna tanpa API browser, tampilkan fallback sampai komponen selesai mount.
'use client'
import { useEffect, useState } from 'react'
export default function ClientThemeOnly() {
const [mounted, setMounted] = useState(false)
const [theme, setTheme] = useState('light')
useEffect(() => {
setMounted(true)
const saved = localStorage.getItem('theme')
if (saved === 'light' || saved === 'dark') {
setTheme(saved)
}
}, [])
if (!mounted) {
return <p>Memuat preferensi...</p>
}
return <p>Tema aktif: {theme}</p>
}
Pola ini mencegah server menebak nilai browser. Cocok untuk UI sekunder, tetapi ada trade-off:
- Konten tidak langsung tersedia di HTML awal.
- Pengalaman bisa terasa terlambat jika fallback terlalu banyak.
- SEO untuk konten tersebut menurun karena server tidak merender hasil finalnya.
3. Client-only boundary dengan dynamic import tanpa SSR
Jika komponen sangat bergantung pada browser dan sulit dibuat stabil pada SSR, Anda bisa menonaktifkan SSR untuk komponen itu.
import dynamic from 'next/dynamic'
const ThemePanel = dynamic(() => import('./ThemePanel'), {
ssr: false,
})
export default function Page() {
return <ThemePanel />
}
Kapan dipilih?
- Komponen memakai library yang hanya berjalan di browser.
- Output awal tidak penting untuk SEO.
- Biaya memperbaiki SSR lebih besar daripada manfaatnya.
Trade-off:
- Komponen tidak ikut HTML awal dari server.
- Time-to-interactive untuk area itu bisa terasa lebih lambat.
- Tidak ideal untuk konten utama halaman.
4. Pisahkan Server Component dan Client Component dengan tanggung jawab yang jelas
Gunakan server untuk data yang stabil dan bisa ditentukan saat request/build, lalu serahkan interaksi browser ke child client component.
// Server Component
import PreferencesPanel from './PreferencesPanel'
export default async function Page() {
const products = await getProducts()
return (
<>
<h2>Produk</h2>
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
<PreferencesPanel />
</>
)
}
'use client'
import { useEffect, useState } from 'react'
export default function PreferencesPanel() {
const [layout, setLayout] = useState('grid')
useEffect(() => {
const saved = localStorage.getItem('layout')
if (saved === 'grid' || saved === 'list') {
setLayout(saved)
}
}, [])
return <p>Layout pilihan: {layout}</p>
}
Dengan pola ini, konten utama tetap stabil dan ramah SSR, sementara preferensi browser diproses terpisah.
5. Hindari nilai non-deterministik pada JSX awal
Untuk waktu, random value, dan ID dinamis:
- hasilkan nilainya di server dan kirim sebagai prop jika memang harus tampil saat SSR
- atau tunda tampilannya sampai setelah mount
- atau gunakan placeholder stabil yang sama di server dan client
Contoh yang lebih aman:
'use client'
import { useEffect, useState } from 'react'
export default function ClientClock() {
const [time, setTime] = useState('—')
useEffect(() => {
setTime(new Date().toLocaleTimeString())
}, [])
return <time>{time}</time>
}
Ini menghindari mismatch, meski ada kompromi berupa placeholder awal.
Kasus khusus: media query, tema, dan preferensi sistem
Media query
Masalah umum terjadi saat layout atau teks bergantung pada ukuran viewport atau preferensi sistem. Server tidak tahu ukuran viewport aktual pengguna, sehingga hasil SSR tidak bisa diandalkan untuk logika seperti:
- mobile vs desktop
- dark mode dari
prefers-color-scheme - reduced motion
Pendekatan yang lebih aman:
- Gunakan CSS responsif bila memungkinkan, bukan conditional render di React.
- Jika perlu state JavaScript, mulai dari default stabil lalu update di
useEffect. - Untuk tema, pertimbangkan strategi yang menyetel class/theme sedini mungkin agar flicker berkurang.
Tema dari localStorage
Dark mode sering menimbulkan flicker: server merender tema default, lalu browser mengganti ke tema tersimpan. Untuk mengurangi efek ini:
- simpan preferensi dalam cookie bila Anda ingin server ikut mengetahui state awal
- atau injeksikan mekanisme inisialisasi tema lebih awal sebelum UI terlihat penuh
- tetap jaga agar render React pertama konsisten dengan HTML awal
Jika state awal tema bisa dibaca server dari cookie, mismatch dan flicker biasanya lebih mudah dikurangi dibanding hanya mengandalkan localStorage.
Kapan memakai masing-masing solusi?
Pilih initial state stabil + useEffect jika:
- perubahan setelah mount masih bisa diterima
- kontennya tetap berguna dengan default awal
- Anda ingin tetap mendapatkan SSR
Pilih mounted guard jika:
- komponen tidak valid tanpa API browser
- lebih baik tampil fallback daripada tampil salah lalu berubah
- konten tersebut bukan bagian SEO utama
Pilih dynamic import tanpa SSR jika:
- komponen atau library memang browser-only
- memperbaiki SSR terlalu rumit
- komponen bukan konten utama halaman
Pilih pemisahan Server/Client Component jika:
- Anda ingin konten utama tetap stabil dan cepat dirender server
- state browser hanya relevan pada sebagian kecil UI
- ingin arsitektur yang lebih mudah dipelihara
Kesalahan umum yang sering memicu hydration issue
- Menganggap
'use client'otomatis aman dari mismatch. - Membaca
localStoragedi initializer state lalu mengira guardtypeof windowsudah cukup. - Merender waktu, random value, atau ID dinamis langsung di JSX awal.
- Menggunakan media query JS untuk mengontrol struktur markup yang dirender server.
- Menggabungkan terlalu banyak logika browser-only ke komponen yang juga penting untuk SSR.
Checklist pencegahan render mismatch di Next.js App Router
- Pastikan render pertama menghasilkan output yang sama di server dan client.
- Jangan akses
window,document,localStorage, ataumatchMediauntuk menentukan JSX awal. - Hindari
Date.now(),new Date(), danMath.random()pada output SSR yang harus konsisten. - Gunakan
useEffectuntuk sinkronisasi state berbasis browser setelah mount. - Pisahkan UI penting untuk SEO dari UI preferensi pengguna yang hanya relevan di client.
- Pertimbangkan
dynamic(..., { ssr: false })hanya untuk komponen yang benar-benar browser-only. - Untuk tema atau preferensi yang memengaruhi tampilan awal, pertimbangkan sumber state yang bisa dibaca server, seperti cookie.
- Gunakan CSS responsif jika kebutuhan hanya soal layout, bukan conditional render data.
Trade-off SEO dan performa
Tidak ada satu solusi yang selalu terbaik. Menghindari mismatch sering berarti memilih salah satu kompromi berikut:
- SSR penuh: baik untuk SEO dan perceived performance, tetapi butuh state awal yang deterministik.
- Update setelah mount: aman untuk hydration, tetapi bisa menimbulkan flicker.
- Client-only render: sederhana untuk komponen browser-only, tetapi mengurangi manfaat SSR dan SEO pada area tersebut.
Prinsip praktisnya: pertahankan SSR untuk konten utama yang harus stabil, dan isolasi state browser pada area interaktif atau preferensi pengguna. Dengan begitu, Anda mendapatkan keseimbangan yang lebih baik antara stabilitas hydration, pengalaman pengguna, dan manfaat SEO/performa dari App Router.
Penutup
Saat menghadapi render mismatch dari state client di App Router, fokuslah pada satu pertanyaan inti: apakah output render pertama di server sama dengan output render pertama di browser? Jika jawabannya tidak, cari sumber state yang hanya tersedia di client atau nilainya berubah-ubah, lalu pilih pola perbaikan yang sesuai: initial state stabil, sinkronisasi di useEffect, mounted guard, client-only boundary, atau pemisahan Server dan Client Component.
Dengan pendekatan ini, warning hydration akan lebih mudah dilacak, flicker bisa dikurangi, dan arsitektur komponen Next.js Anda akan lebih konsisten serta mudah dipelihara.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!