SSR chat streaming tanpa hydration mismatch di Next.js berarti satu hal: HTML awal yang dirender server harus cukup stabil sehingga React di client bisa melakukan hydration tanpa menemukan perbedaan struktur atau isi yang tidak terduga. Pada UI chat real-time, masalah ini sering muncul karena daftar pesan, status koneksi peer, timestamp, dan token streaming berubah di sela-sela render server dan hydration client.
Jika Anda membangun antarmuka mirip komputasi terdistribusi atau mesh-style LLM UI—misalnya ada beberapa peer, status koneksi, token yang mengalir sedikit demi sedikit, dan event sinkronisasi—maka sumber mismatch bukan hanya pesan chat. Status transport, urutan event, waktu lokal, dan data yang datang dari browser API juga sering memicu warning seperti Text content does not match server-rendered HTML atau UI yang terasa “meloncat” setelah halaman aktif di client.
Mengapa hydration mismatch sering terjadi pada chat streaming
Hydration mismatch muncul ketika output render pertama di browser tidak sama dengan HTML yang sebelumnya dikirim server. Pada aplikasi chat streaming, ini mudah terjadi karena data real-time berubah sangat cepat, sementara SSR menghasilkan snapshot pada satu titik waktu tertentu.
Gejala yang paling umum
- Daftar pesan berubah urutan saat client menerima event baru sebelum hydration selesai.
- Status peer/koneksi berbeda karena server tidak tahu kondisi WebRTC, WebSocket, atau koneksi lokal browser saat SSR.
- Timestamp berbeda karena server dan client memformat waktu dengan zona waktu, locale, atau waktu saat ini yang berbeda.
- Token streaming sudah bertambah di client tetapi HTML server masih berisi token lama atau placeholder kosong.
- Conditional rendering berbeda, misalnya server menampilkan “connecting”, tetapi client langsung punya status “connected”.
Akar masalahnya
Masalah inti bukan pada SSR itu sendiri, melainkan pada ketidakstabilan input render awal. React mengharapkan bahwa render pertama di client, sebelum efek berjalan, menghasilkan struktur DOM yang setara dengan hasil render server. Jika input awal di client berasal dari sumber yang berbeda—waktu lokal, window, status koneksi, stream token aktif, atau store yang sudah berubah—maka mismatch sangat mungkin terjadi.
Aturan praktis: apa pun yang tidak tersedia atau tidak stabil saat SSR sebaiknya tidak ikut menentukan markup awal.
Pola arsitektur yang aman untuk UI chat real-time
Pola yang paling aman adalah memisahkan snapshot SSR yang stabil dari lapisan real-time di client. Server mengirimkan data awal yang konsisten, lalu client melanjutkan pembaruan setelah hydration selesai.
1. Server render hanya untuk snapshot awal
Gunakan server component atau data fetch di server untuk menghasilkan:
- daftar pesan awal yang sudah tersimpan,
- status yang bersifat statis atau konservatif,
- placeholder untuk bagian yang akan hidup di client.
Hindari memasukkan data yang hanya bisa diketahui browser saat runtime, seperti status peer aktual, latensi koneksi saat ini, atau token yang sedang mengalir tepat pada detik itu.
2. Client component menangani stream dan status koneksi
Semua hal yang bergantung pada event real-time sebaiknya dipindahkan ke client component. Ini mencakup:
- streaming token,
- status peer online/offline/connecting,
- indikator sinkronisasi,
- retry, reconnect, dan event transport.
Dengan begitu, server tidak perlu menebak kondisi yang sangat volatil.
3. Gunakan state awal yang identik dengan output SSR
Jika server merender 10 pesan dan satu status “menyambungkan…”, maka state awal client harus memulai dari data yang sama persis. Jangan langsung menginisialisasi state dari sumber lain yang mungkin lebih baru sebelum hydration selesai.
Contoh struktur komponen yang aman di Next.js
Berikut contoh pendekatan ringkas: server component menyiapkan snapshot, lalu client component melanjutkan event real-time.
// app/chat/[roomId]/page.tsx
import ChatShell from './ChatShell';
export default async function ChatPage({ params }: { params: { roomId: string } }) {
const initialMessages = await getInitialMessages(params.roomId);
return (
<ChatShell
roomId={params.roomId}
initialMessages={initialMessages}
/>
);
}
async function getInitialMessages(roomId: string) {
// Ambil snapshot stabil dari database atau API internal.
return [
{
id: 'm1',
role: 'user',
content: 'Halo',
createdAt: '2026-07-12T10:00:00.000Z'
}
];
}// app/chat/[roomId]/ChatShell.tsx
import ClientChat from './ClientChat';
export default function ChatShell({
roomId,
initialMessages
}: {
roomId: string;
initialMessages: Array<{
id: string;
role: string;
content: string;
createdAt: string;
}>;
}) {
return (
<section>
<h2>Room {roomId}</h2>
<ClientChat roomId={roomId} initialMessages={initialMessages} />
</section>
);
}// app/chat/[roomId]/ClientChat.tsx
'use client';
import { useEffect, useMemo, useState } from 'react';
type Message = {
id: string;
role: string;
content: string;
createdAt: string;
};
type PeerStatus = 'idle' | 'connecting' | 'connected' | 'disconnected';
export default function ClientChat({
roomId,
initialMessages
}: {
roomId: string;
initialMessages: Message[];
}) {
const [messages, setMessages] = useState(initialMessages);
const [peerStatus, setPeerStatus] = useState<PeerStatus>('connecting');
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
const connection = connectToRoom(roomId);
connection.onStatus((status) => {
setPeerStatus(status);
});
connection.onToken(({ messageId, token }) => {
setMessages((prev) => {
const next = [...prev];
const idx = next.findIndex((m) => m.id === messageId);
if (idx === -1) {
next.push({
id: messageId,
role: 'assistant',
content: token,
createdAt: new Date().toISOString()
});
} else {
next[idx] = {
...next[idx],
content: next[idx].content + token
};
}
return next;
});
});
connection.onMessage((message) => {
setMessages((prev) => {
if (prev.some((m) => m.id === message.id)) return prev;
return [...prev, message];
});
});
return () => connection.close();
}, [roomId]);
const statusLabel = useMemo(() => {
if (!hydrated) return 'Menyambungkan...';
if (peerStatus === 'connected') return 'Terhubung';
if (peerStatus === 'disconnected') return 'Terputus';
return 'Menyambungkan...';
}, [hydrated, peerStatus]);
return (
<div>
<div aria-live="polite">Status peer: {statusLabel}</div>
<ul>
{messages.map((message) => (
<li key={message.id}>
<strong>{message.role}:</strong> {message.content}
</li>
))}
</ul>
</div>
);
}
function connectToRoom(roomId: string) {
return {
onStatus(cb: (status: PeerStatus) => void) {
setTimeout(() => cb('connected'), 300);
},
onToken(cb: (data: { messageId: string; token: string }) => void) {
// Simulasi stream token
},
onMessage(cb: (message: Message) => void) {
// Simulasi pesan lengkap
},
close() {}
};
}Poin penting dari contoh di atas:
- Initial state diambil langsung dari props SSR, bukan dari browser store.
- Status peer awal dibuat konservatif:
connecting. - Event real-time baru dipasang di
useEffect, artinya setelah render awal. - Label status sebelum hydration sengaja dibuat stabil.
Pola render aman untuk bagian yang paling sering mismatch
Daftar pesan
Untuk daftar pesan, masalah umum adalah urutan berubah atau item baru muncul terlalu cepat. Gunakan aturan berikut:
- Render SSR hanya dari snapshot yang sudah final pada saat request diproses.
- Jangan lakukan sort berbasis waktu lokal di client pada render pertama jika server memakai urutan berbeda.
- Gunakan key yang stabil, biasanya ID pesan, bukan indeks array.
- Jika ada stream token untuk pesan baru, lebih aman membuat satu entitas pesan dengan ID tetap lalu memperbarui kontennya, bukan menambah item baru untuk setiap token.
Kesalahan umum:
// Buruk: key memakai index, rentan saat urutan berubah
{messages.map((m, i) => <li key={i}>{m.content}</li>)}Lebih aman:
{messages.map((m) => <li key={m.id}>{m.content}</li>)}Status koneksi peer
Status koneksi sangat sering mismatch karena server tidak punya akses ke kondisi runtime browser. Solusi paling aman adalah:
- SSR menampilkan status netral seperti Menyambungkan... atau Status akan tersedia....
- Client mengganti status aktual setelah hydration.
- Jika status sangat dinamis dan tidak penting untuk SEO, pertimbangkan agar komponen status hanya dirender di client.
Timestamp
Timestamp adalah penyebab mismatch klasik. Server mungkin memformat 12.00, client bisa memformat 19.00 tergantung zona waktu dan locale.
Pilihan yang lebih aman:
- Simpan dan render waktu dalam format yang stabil dari server, misalnya ISO string untuk atribut data.
- Tampilkan placeholder tetap saat SSR, lalu format lokal di client setelah hydration.
- Jika perlu menampilkan waktu absolut di SSR, gunakan format string yang sudah diproduksi server dan jangan diformat ulang pada render awal client.
Contoh aman:
'use client';
import { useEffect, useState } from 'react';
export function LocalTime({ iso }: { iso: string }) {
const [label, setLabel] = useState('...');
useEffect(() => {
const text = new Date(iso).toLocaleTimeString();
setLabel(text);
}, [iso]);
return <time dateTime={iso}>{label}</time>;
}Pendekatan ini menghindari server dan client memformat waktu dengan hasil berbeda pada render awal.
Token streaming
Token streaming sebaiknya dianggap sebagai lapisan progresif di atas snapshot awal, bukan bagian dari HTML SSR yang harus selalu terbaru. Jika stream bisa mulai sebelum hydration selesai, ada dua pendekatan praktis:
- Tunda konsumsi stream sampai effect aktif, sehingga render awal tetap sama dengan server.
- Buffer event token di client, lalu terapkan setelah komponen hydrated.
Contoh pola buffer sederhana:
'use client';
import { useEffect, useRef, useState } from 'react';
export function StreamingMessage({ initialText = '' }: { initialText?: string }) {
const [text, setText] = useState(initialText);
const [hydrated, setHydrated] = useState(false);
const queueRef = useRef<string[]>([]);
useEffect(() => {
setHydrated(true);
for (const token of queueRef.current) {
setText((prev) => prev + token);
}
queueRef.current = [];
}, []);
function onIncomingToken(token: string) {
if (!hydrated) {
queueRef.current.push(token);
return;
}
setText((prev) => prev + token);
}
return <p>{text}</p>;
}Implementasi produksi biasanya akan menghubungkan onIncomingToken ke event WebSocket, SSE, atau transport peer-to-peer.
Kapan perlu memisahkan komponen dengan dynamic import
Ada komponen yang secara alami sulit atau tidak layak di-SSR-kan, misalnya panel status transport, visualisasi peer topology, metrik koneksi langsung dari browser, atau log event yang sangat volatil. Dalam kasus seperti itu, Anda bisa memuatnya hanya di client.
import dynamic from 'next/dynamic';
const PeerDiagnostics = dynamic(() => import('./PeerDiagnostics'), {
ssr: false
});
export default function Sidebar() {
return (
<aside>
<PeerDiagnostics />
</aside>
);
}Kapan memilih ini:
- komponen bergantung pada
window,navigator, atau API browser lain, - nilai awal hampir pasti berbeda antara server dan client,
- komponen tidak penting untuk konten awal halaman.
Trade-off:
- bagian itu tidak ikut SSR,
- konten awal mungkin kosong atau hanya placeholder,
- SEO untuk bagian tersebut tidak relevan atau menurun.
Untuk UI chat real-time, ini sering merupakan trade-off yang masuk akal pada panel diagnostik, bukan pada daftar pesan utama.
Placeholder stabil lebih penting daripada data yang terlalu cepat
Kesalahan umum pada antarmuka real-time adalah mencoba menampilkan “kondisi paling terbaru” sejak SSR. Akibatnya, markup awal berubah sebelum hydration selesai. Lebih baik menampilkan placeholder yang stabil dan jujur daripada angka atau status yang tampak akurat tetapi menyebabkan mismatch.
Contoh placeholder yang aman:
- Status peer: “Menyambungkan...”
- Waktu lokal: “...”
- Statistik sinkronisasi: “Memuat status node...”
- Respons model yang sedang streaming: bubble kosong dengan indikator mengetik, bukan token parsial dari sumber berbeda.
UI real-time yang baik tidak harus selalu menampilkan data paling baru pada HTML awal. Yang lebih penting adalah transisi dari snapshot ke live state terasa jelas dan tidak membingungkan.
Penanganan state awal yang aman
Untuk mencegah render mismatch, tentukan kontrak state awal secara eksplisit:
- messages: berasal dari snapshot SSR.
- peerStatus: mulai dari nilai konservatif seperti
connectingatauunknown. - timestamps lokal: jangan diformat di render awal jika bergantung locale browser.
- stream progress: mulai dari string kosong atau konten snapshot terakhir yang telah dipersisten.
Hindari pola berikut:
// Rentan mismatch jika localStorage atau runtime browser berbeda
const [peerStatus] = useState(() => localStorage.getItem('peerStatus') || 'connecting');Jika Anda memang perlu membaca dari browser storage, lakukan di useEffect dan biarkan render awal tetap cocok dengan SSR.
const [peerStatus, setPeerStatus] = useState('connecting');
useEffect(() => {
const saved = localStorage.getItem('peerStatus');
if (saved) setPeerStatus(saved);
}, []);Kesalahan yang sering terjadi di Next.js
Menggunakan nilai waktu saat render
// Berpotensi mismatch
<p>{Date.now()}</p>
<p>{new Date().toLocaleString()}</p>Nilai ini hampir pasti berbeda antara server dan client.
Mengakses browser API sebelum hydration
// Berbahaya jika memengaruhi render awal
const isOnline = navigator.onLine;Jika nilai ini dipakai untuk output awal, hasil SSR dan client bisa tidak sama.
Mengubah struktur DOM berdasarkan data volatile
Contohnya, server merender tiga bubble, lalu client langsung merender empat bubble pada pass pertama. Bukan hanya teks yang berubah, struktur list juga berbeda.
Sort atau dedup yang tidak deterministik
Jika Anda menggabungkan snapshot SSR dengan event client, pastikan aturan dedup dan sort deterministik. Gunakan ID pesan, urutan event yang jelas, dan hindari comparator yang tergantung waktu sekarang.
Strategi debug saat mismatch sulit dilacak
1. Bekukan data awal
Matikan sementara stream dan status real-time. Pastikan HTML SSR dan render awal client sudah stabil hanya dengan snapshot awal.
2. Log state pada render pertama
Bandingkan:
- props dari server,
- state awal di client,
- nilai setelah
useEffectberjalan.
Sering kali mismatch berasal dari inisialisasi state yang diam-diam membaca sumber berbeda.
3. Audit semua nilai non-deterministik
Cari penggunaan:
Date.now(),new Date(),Math.random(),- API browser seperti
window,navigator,localStorage, - locale/timezone formatting.
4. Uji dengan koneksi lambat
Pada koneksi lambat, jarak waktu antara SSR dan hydration lebih besar, sehingga bug lebih mudah muncul. Ini sangat membantu untuk UI streaming.
5. Pisahkan panel diagnostik dari konten inti
Jika mismatch muncul dari panel status, topology graph, atau metrik live, pindahkan dulu komponen itu ke client-only. Setelah konten utama stabil, baru optimalkan lebih lanjut.
6. Verifikasi key pada list
List yang berubah cepat sangat sensitif terhadap key yang salah. Pastikan tidak ada key berbasis indeks untuk pesan atau peer.
Checklist pencegahan SSR chat streaming tanpa hydration mismatch
- Gunakan snapshot SSR yang stabil untuk daftar pesan awal.
- Inisialisasi state client dari props SSR yang sama persis.
- Pasang koneksi stream, peer, atau subscription di
useEffect. - Gunakan placeholder stabil untuk status koneksi dan waktu lokal.
- Jangan format timestamp lokal pada render awal jika hasilnya bisa berbeda.
- Gunakan key list yang stabil, biasanya ID.
- Jangan membaca
window,navigator, ataulocalStorageuntuk menentukan markup awal. - Buffer atau tunda token streaming sampai hydration aman.
- Pertimbangkan
dynamic importdenganssr: falseuntuk panel live yang sangat volatil. - Uji pada koneksi lambat dan saat event real-time datang tepat setelah halaman dimuat.
Penutup
Membangun UI chat real-time bergaya sistem terdistribusi di Next.js bukan sekadar soal menerima token secepat mungkin. Tantangan utamanya adalah menjaga agar SSR tetap menghasilkan HTML yang konsisten, lalu membiarkan client mengambil alih pembaruan live tanpa membuat React bingung saat hydration.
Pendekatan yang paling aman biasanya sederhana: server merender snapshot stabil, client mengelola data volatile. Dengan pemisahan komponen yang jelas, placeholder yang jujur, state awal yang deterministik, dan debugging yang disiplin, Anda bisa membuat antarmuka chat streaming yang terasa real-time tanpa warning hydration dan tanpa pengalaman UI yang membingungkan bagi pengguna.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!