Hydration mismatch pada avatar animasi, TTS, atau komponen interaktif di Next.js hampir selalu terjadi karena output render server tidak identik dengan render pertama di client. Gejalanya bisa berupa warning React, animasi meloncat, avatar berkedip saat load, tombol play/pause tidak sinkron, atau elemen yang tiba-tiba berubah setelah hydration.

Dalam konteks UI bergaya AI Avatar modern, masalah ini makin sering muncul karena komponen bergantung pada window, matchMedia, ukuran viewport, waktu saat ini, status audio, atau event dari extension browser. Solusinya bukan sekadar “matikan SSR”, melainkan mengaudit bagian mana yang tidak deterministik, lalu menerapkan batas client-only, placeholder stabil, dan pemisahan state antara server dan browser.

Mengapa hydration mismatch terjadi pada SSR avatar animasi

Pada SSR, Next.js merender HTML di server lebih dulu. Setelah halaman sampai ke browser, React melakukan hydration: ia menempelkan event handler dan menyamakan tree React dengan HTML yang sudah ada. Jika struktur, teks, atribut, atau urutan elemen berbeda, React akan memberi warning seperti:

Warning: Text content did not match.
Hydration failed because the initial UI does not match what was rendered on the server.

Komponen avatar animasi rentan karena sering memakai data yang hanya tersedia di browser atau berubah dari waktu ke waktu. Contoh umum:

  • Status isSpeaking berasal dari Web Audio API atau TTS event.
  • Animasi awal ditentukan dari ukuran viewport.
  • Blink avatar dihitung dari Date.now() atau Math.random().
  • Microphone permission memengaruhi state awal.
  • Extension browser menyuntik atribut ke DOM.

Jika server merender avatar dalam keadaan “diam”, tetapi browser langsung merender “sedang bicara”, mismatch sangat mungkin terjadi.

Gejala yang sering muncul

1. Warning React saat hydration

Ini gejala paling jelas. Periksa console browser saat mode development.

2. Avatar berkedip atau meloncat

HTML awal menampilkan pose A, lalu setelah hydration berubah ke pose B. Perubahan ini kadang tidak memunculkan error fatal, tetapi tetap merusak UX.

3. Tombol audio atau TTS tidak sinkron

Misalnya server merender label Play, tetapi client langsung tahu bahwa audio sedang berjalan sehingga label berubah menjadi Pause.

4. Layout shift pada komponen interaktif

Jika ukuran avatar ditentukan dari viewport atau hasil pengukuran elemen, SSR sering menghasilkan ukuran berbeda dari client.

Root cause paling umum pada komponen avatar, TTS, dan efek interaktif

Browser API dipakai saat render

Pemanggilan window, document, localStorage, speechSynthesis, AudioContext, atau matchMedia di fase render hampir selalu bermasalah untuk SSR.

// Bermasalah: hasil render bergantung pada browser API
export function Avatar() {
  const isMobile = window.matchMedia('(max-width: 768px)').matches;
  return <div>{isMobile ? 'mobile' : 'desktop'}</div>;
}

Server tidak punya window, dan bahkan jika diguard, hasil render server dan client bisa berbeda.

Nilai non-deterministik

Nilai dari Date.now(), new Date(), Math.random(), atau ID yang berubah antar environment dapat menghasilkan markup berbeda.

// Hindari sebagai dasar initial render SSR
const frame = Math.random() > 0.5 ? 'blink' : 'idle';

State awal berasal dari audio atau TTS

Status seperti “sedang bicara”, “mic aktif”, “audio ter-buffer”, atau “voice tersedia” biasanya baru diketahui setelah browser siap. Jika state ini dipakai untuk markup awal, mismatch mudah terjadi.

Viewport dan pengukuran elemen

Logika seperti window.innerWidth, ResizeObserver, atau pengukuran canvas/SVG tidak stabil untuk SSR. Render server tidak bisa mengetahui ukuran final di browser.

Extension browser mengubah DOM

Beberapa extension menambahkan atribut, membungkus node, atau memodifikasi input. Ini dapat memicu warning hydration yang terlihat seperti bug aplikasi, padahal sumbernya eksternal.

Jika mismatch hanya terjadi di browser tertentu atau hanya pada mesin developer tertentu, selalu curigai extension lebih dulu.

Langkah audit: cari sumber mismatch secara sistematis

1. Mulai dari pesan warning yang paling spesifik

React biasanya memberi petunjuk node mana yang tidak cocok: teks, atribut, atau subtree tertentu. Fokus ke komponen avatar, audio widget, atau indikator state interaktif.

2. Bedakan data server dan data client

Tanyakan untuk setiap nilai yang dipakai saat render:

  • Apakah nilai ini tersedia di server?
  • Apakah nilainya bisa berubah antara SSR dan hydration?
  • Apakah nilainya bergantung pada browser, waktu, audio, viewport, atau extension?

3. Matikan animasi dan efek sementara

Nonaktifkan sumber variasi satu per satu: blinking otomatis, waveform, TTS state, viewport-based layout, atau random idle motion. Jika warning hilang, Anda sudah menemukan kelas masalahnya.

4. Uji dengan placeholder statis

Ganti avatar animasi dengan markup sederhana yang identik di server dan client. Jika mismatch hilang, maka masalah ada pada logika render awal komponen tersebut, bukan pada layout halaman secara umum.

5. Periksa mode private atau browser tanpa extension

Ini langkah murah tetapi sering menyelamatkan waktu debugging.

6. Audit penggunaan hook dan initial state

Banyak bug berasal dari pola seperti:

const [isSpeaking, setIsSpeaking] = useState(checkAudioState());

Jika checkAudioState() hanya valid di browser, initial state server dan client bisa berbeda.

Pola perbaikan yang paling aman

1. Pindahkan akses browser API ke useEffect

useEffect hanya berjalan di client setelah render awal. Ini membuat HTML SSR dan render awal client tetap stabil.

import { useEffect, useState } from 'react';

export function AvatarStatus() {
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    const media = window.matchMedia('(max-width: 768px)');
    setIsMobile(media.matches);

    const onChange = (e) => setIsMobile(e.matches);
    media.addEventListener?.('change', onChange);
    return () => media.removeEventListener?.('change', onChange);
  }, []);

  return <div>{isMobile ? 'mobile' : 'desktop'}</div>;
}

Mengapa ini bekerja: server dan render awal client sama-sama memakai false sebagai nilai awal. Setelah hydration selesai, state diperbarui dengan data browser aktual.

Trade-off: ada kemungkinan UI berubah sesaat setelah mount. Jika perubahan ini sensitif, gunakan placeholder atau desain yang tidak membuat lompatan visual besar.

2. Gunakan client-only boundary untuk komponen yang memang tidak cocok di-SSR

Jika avatar sangat bergantung pada canvas, audio engine, atau TTS event, lebih aman memuatnya hanya di browser.

import dynamic from 'next/dynamic';

const AnimatedAvatar = dynamic(() => import('./AnimatedAvatarClient'), {
  ssr: false,
  loading: () => <div className="avatar-skeleton" aria-hidden="true" />
});

export default function ProfileHero() {
  return <AnimatedAvatar />;
}

Kapan dipilih: saat komponen hampir seluruhnya bergantung pada browser API dan tidak memberi nilai SEO penting sebagai HTML awal.

Trade-off: konten komponen tidak hadir di HTML server, sehingga SEO dan kecepatan tampilan awal untuk bagian itu bisa menurun. Namun untuk avatar dekoratif atau widget audio, ini sering layak.

3. Buat placeholder yang stabil antara server dan client

Jangan biarkan state awal berbeda. Render versi netral yang identik di kedua sisi, lalu upgrade setelah mount.

import { useEffect, useState } from 'react';

export function TalkingAvatar() {
  const [mounted, setMounted] = useState(false);
  const [isSpeaking, setIsSpeaking] = useState(false);

  useEffect(() => {
    setMounted(true);
    // hubungkan ke event TTS/audio di sini
  }, []);

  return (
    <div className={mounted && isSpeaking ? 'avatar speaking' : 'avatar idle'}>
      <img src="/avatar-idle.png" alt="Avatar AI" />
    </div>
  );
}

Di sini, state awal selalu idle. Setelah mount, event audio dapat mengubah kelas CSS dengan aman.

4. Pisahkan state server dan state client

Data yang benar-benar berasal dari server, seperti nama pengguna, URL avatar, atau preferensi yang sudah tersimpan di backend, boleh dirender saat SSR. Sebaliknya, state yang hanya valid di browser harus dipisahkan.

export default function AvatarCard({ user }) {
  return (
    <section>
      <img src={user.avatarUrl} alt={user.name} />
      <p>{user.name}</p>
      <ClientAudioStatus />
    </section>
  );
}

Pemisahan ini membuat HTML server tetap kaya konten tanpa memaksa status audio atau animasi ikut di-SSR.

5. Hindari nilai acak atau berbasis waktu pada initial render

Jika butuh animasi acak, hasilkan setelah mount atau gunakan seed yang konsisten dari server. Untuk kasus sederhana, tunda saja ke client.

const [blink, setBlink] = useState(false);

useEffect(() => {
  const id = setInterval(() => {
    setBlink((v) => !v);
  }, 3000);
  return () => clearInterval(id);
}, []);

Jangan gunakan waktu saat ini untuk menentukan pose awal saat SSR.

6. Gunakan guard client dengan hati-hati

Pola seperti berikut berguna, tetapi jangan dipakai untuk menyembunyikan masalah arsitektur secara membabi buta.

const isClient = typeof window !== 'undefined';

Jika dipakai langsung dalam render untuk memilih markup berbeda, mismatch tetap bisa terjadi. Guard lebih aman dipakai di dalam useEffect atau komponen yang memang hanya dirender di client.

Contoh perbaikan end-to-end pada avatar TTS

Misalkan Anda punya avatar yang membuka mulut saat TTS berjalan. Implementasi awal sering seperti ini:

// Contoh bermasalah
export function AvatarTTS() {
  const isSpeaking = window.speechSynthesis?.speaking ?? false;

  return (
    <div className={isSpeaking ? 'avatar speaking' : 'avatar idle'}>
      <img src={isSpeaking ? '/talking.png' : '/idle.png'} alt="Avatar" />
    </div>
  );
}

Masalahnya jelas: state awal diambil dari browser API saat render.

Versi yang lebih aman:

import { useEffect, useState } from 'react';

export function AvatarTTS() {
  const [mounted, setMounted] = useState(false);
  const [isSpeaking, setIsSpeaking] = useState(false);

  useEffect(() => {
    setMounted(true);

    const sync = () => {
      const speaking = window.speechSynthesis?.speaking ?? false;
      setIsSpeaking(speaking);
    };

    sync();
    const id = setInterval(sync, 200);
    return () => clearInterval(id);
  }, []);

  const stateClass = mounted && isSpeaking ? 'avatar speaking' : 'avatar idle';
  const imageSrc = mounted && isSpeaking ? '/talking.png' : '/idle.png';

  return (
    <div className={stateClass}>
      <img src={imageSrc} alt="Avatar AI" width="256" height="256" />
    </div>
  );
}

Pola ini membuat SSR dan hydration awal selalu konsisten: avatar mulai dari idle, lalu baru bereaksi setelah client siap.

Kapan memakai dynamic import ssr:false, kapan tidak

Pakai ssr: false jika

  • Komponen sangat bergantung pada browser API.
  • Markup awal komponen tidak penting untuk SEO.
  • Anda ingin mengisolasi area yang rawan mismatch dengan cepat.
  • Avatar memakai canvas, WebGL, Web Audio, atau library animasi yang tidak SSR-friendly.

Jangan langsung pakai ssr: false jika

  • Komponen memuat konten penting yang seharusnya hadir di HTML awal.
  • Masalah sebenarnya hanya satu state kecil yang bisa dipindah ke useEffect.
  • Anda bisa merender placeholder semantik yang stabil tanpa kehilangan UX besar.

Pendekatan terbaik sering berupa kombinasi: konten utama tetap SSR, sedangkan lapisan interaktif avatar dimuat di client.

Trade-off: SEO, performa, dan UX

SEO

Jika avatar hanya elemen visual, memindahkannya ke client-only biasanya aman. Namun jika area itu juga berisi teks, nama, status, atau CTA penting, pertahankan bagian informatif di SSR.

Performa

SSR memberi first paint yang lebih cepat untuk HTML awal, tetapi hydration yang gagal justru bisa memicu render ulang dan pengalaman yang terasa kasar. Client-only mengurangi risiko mismatch, tetapi menunda interaktivitas komponen sampai JavaScript selesai dimuat.

UX

Placeholder stabil sering lebih baik daripada animasi “langsung aktif” tetapi glitch. Untuk avatar AI modern, pengguna biasanya lebih memaafkan avatar yang mulai diam lalu aktif sesaat kemudian, dibanding avatar yang flicker atau salah state saat load.

Checklist debugging hydration mismatch di Next.js

  1. Periksa warning React dan identifikasi node yang berbeda.
  2. Cari pemakaian window, document, localStorage, speechSynthesis, AudioContext, dan matchMedia.
  3. Cari initial state yang dihitung dari browser API.
  4. Cari penggunaan Date.now(), new Date(), Math.random(), atau ID non-deterministik saat render.
  5. Nonaktifkan animasi, waveform, atau blinking untuk isolasi masalah.
  6. Pastikan ukuran komponen tidak bergantung pada viewport saat SSR.
  7. Uji di browser tanpa extension.
  8. Pindahkan logika client ke useEffect atau buat komponen client-only.
  9. Gunakan placeholder yang identik di server dan client.
  10. Pisahkan data SSR yang statis dari state interaktif browser.

Kesalahan umum yang sering dilakukan

  • Mengakses browser API di body komponen, bukan di effect.
  • Menggunakan guard typeof window langsung untuk menghasilkan markup berbeda saat render.
  • Merender status audio real-time saat SSR, padahal nilainya baru valid di client.
  • Mengikat layout ke ukuran viewport awal tanpa fallback yang stabil.
  • Menyelesaikan semua masalah dengan mematikan SSR, padahal sebagian bisa diperbaiki lebih presisi.

Penutup

Untuk debug SSR avatar animasi di Next.js, prinsip utamanya sederhana: render awal harus deterministik dan identik antara server dan client. Begitu komponen bergantung pada browser API, waktu, viewport, audio state, atau event extension, perlakukan bagian itu sebagai state client, bukan fakta yang tersedia saat SSR.

Mulailah dari audit sumber variasi, lalu pilih pola perbaikan yang paling sesuai: useEffect untuk sinkronisasi client, dynamic import dengan ssr: false untuk komponen yang memang browser-only, placeholder stabil untuk mencegah flicker, dan pemisahan state server/client agar SEO, performa, dan UX tetap seimbang.