SSR Skeleton Stabil bukan sekadar soal tampilan loading yang rapi. Tujuan utamanya adalah memastikan HTML hasil server tetap cocok dengan render awal di client saat proses hydration berjalan. Jika konten berubah terlalu cepat karena waktu, nilai acak, localStorage, media query, atau data async, React dapat mendeteksi perbedaan struktur/teks dan memunculkan hydration mismatch.

Masalah ini sering muncul pada aplikasi React/Next.js yang terlihat benar setelah interaktif, tetapi rapuh pada titik tekan awal render. Analogi sederhananya: struktur awal UI harus kuat di bagian “gigi”-nya, yaitu area yang pertama kali menahan beban saat SSR dan hydration, bukan hanya indah setelah semua state client selesai dimuat. Referensi tentang material alami yang sangat kuat bisa dipakai sebagai konteks analogi ini: kekuatan awal lebih penting daripada sekadar hasil akhir yang tampak halus.

Apa itu hydration mismatch dan mengapa sering terjadi?

Pada SSR, server mengirim HTML awal ke browser. Setelah itu, React melakukan hydration: mengaitkan event handler dan state ke markup yang sudah ada. Masalah muncul ketika render pertama di client menghasilkan output berbeda dari HTML server.

Perbedaan itu bisa berupa:

  • teks yang berbeda, misalnya tanggal atau jam,
  • jumlah elemen yang berbeda,
  • atribut berbeda,
  • percabangan render yang berubah karena environment browser,
  • hasil data async yang datang terlalu cepat atau berbeda sumber.

Intinya: render pertama client harus deterministik dan kompatibel dengan render server. Kalau belum tahu nilai yang benar di client, tampilkan placeholder yang stabil dulu.

Penyebab umum mismatch pada React/Next.js

1. Nilai waktu yang berubah saat render

Contoh klasik adalah memanggil new Date() langsung di JSX. Waktu saat server merender hampir pasti berbeda dengan waktu saat client melakukan render awal.

// Anti-pattern
export function HeaderTime() {
  return <p>Dibuat: {new Date().toLocaleTimeString()}</p>;
}

Jika server mengirim 10:00:01 dan client merender 10:00:02, React melihat perbedaan teks.

2. Nilai random saat render

Penggunaan Math.random(), generator ID non-deterministik, atau pemilihan konten acak langsung saat render akan menghasilkan output berbeda antara server dan client.

// Anti-pattern
export function PromoBadge() {
  const variant = Math.random() > 0.5 ? 'A' : 'B';
  return <span>Promo {variant}</span>;
}

3. Membaca localStorage terlalu awal

localStorage hanya tersedia di browser. Jika state awal komponen bergantung pada nilai tersebut saat render pertama, hasil server dan client akan berbeda atau bahkan memunculkan error jika akses dilakukan tanpa guard.

// Anti-pattern
export function ThemeLabel() {
  const theme = localStorage.getItem('theme') || 'light';
  return <p>Tema: {theme}</p>;
}

4. Media query dan ukuran viewport

Server tidak benar-benar tahu lebar viewport user saat HTML dirender. Jika Anda merender struktur DOM berbeda berdasarkan window.matchMedia atau window.innerWidth pada render pertama, mismatch mudah terjadi.

5. Data async yang berubah sangat cepat

Kasus ini terjadi ketika server merender data A, tetapi client langsung mengambil data B atau membaca cache yang lebih baru sebelum hydration selesai. Walau akhirnya UI benar, render awal sudah tidak konsisten.

Prinsip utama SSR skeleton yang stabil

Solusi yang aman bukan selalu “hindari SSR”, melainkan pisahkan output awal yang harus stabil dari konten client yang baru boleh berubah setelah mount.

1. Render awal harus deterministik

Semua yang dirender di server harus dapat direproduksi oleh client pada render pertama. Jika tidak bisa, jangan render nilai akhirnya dulu. Render placeholder yang sama di kedua sisi.

2. Skeleton harus mempertahankan struktur DOM

Skeleton yang baik tidak hanya abu-abu bergerak. Ia menjaga ukuran, urutan elemen, dan hierarki DOM agar transisi dari SSR ke state interaktif tidak mengubah struktur secara drastis.

3. Update berbasis browser dilakukan setelah mount

Nilai yang hanya tersedia di client sebaiknya dibaca di useEffect atau komponen client-only, sehingga render awal tetap aman.

4. Bedakan “unknown” dengan “empty”

Jangan langsung menampilkan “tidak ada data” jika data sebenarnya belum diketahui. Gunakan state terpisah seperti loading, ready, atau placeholder eksplisit.

Anti-pattern dan perbaikannya

Anti-pattern 1: Menghitung nilai non-deterministik langsung di render

// Sebelum
export function LastUpdated() {
  return <span>{new Date().toLocaleString()}</span>;
}

Perbaikan: kirim nilai dari server sebagai prop jika memang bagian dari data SSR, atau tampilkan placeholder stabil lalu isi setelah mount.

// Sesudah: placeholder deterministik + update setelah mount
'use client';

import { useEffect, useState } from 'react';

export function LastUpdatedClient() {
  const [text, setText] = useState('Memuat waktu...');

  useEffect(() => {
    setText(new Date().toLocaleString());
  }, []);

  return <span>{text}</span>;
}

Mengapa ini bekerja? Karena server dan client sama-sama merender Memuat waktu... pada tahap awal. Perubahan baru terjadi setelah hydration selesai.

Anti-pattern 2: Menggunakan localStorage sebagai state awal render

// Sebelum
'use client';

export function ThemeSwitch() {
  const initialTheme = localStorage.getItem('theme') || 'light';
  return <div>Tema aktif: {initialTheme}</div>;
}

Perbaikan: mulai dari nilai fallback yang stabil, lalu baca localStorage di effect.

// Sesudah
'use client';

import { useEffect, useState } from 'react';

export function ThemeSwitch() {
  const [theme, setTheme] = useState('light');
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const saved = window.localStorage.getItem('theme');
    if (saved === 'light' || saved === 'dark') {
      setTheme(saved);
    }
    setReady(true);
  }, []);

  return (
    <div>
      <span>Tema aktif: {ready ? theme : 'Memuat preferensi...'}</span>
    </div>
  );
}

Trade-off-nya, user mungkin melihat placeholder singkat sebelum preferensi terpasang. Namun ini lebih aman daripada mismatch. Jika tema harus diterapkan sebelum paint untuk menghindari flash, solusi biasanya dipindahkan ke level dokumen/layout dengan mekanisme yang memang dirancang untuk sinkronisasi awal, bukan membaca localStorage sembarang di render komponen.

Anti-pattern 3: Render berbeda untuk mobile/desktop berdasarkan window

// Sebelum
'use client';

export function Nav() {
  const mobile = window.innerWidth < 768;
  return mobile ? <MobileNav /> : <DesktopNav />;
}

Perbaikan: gunakan struktur HTML yang sama sebanyak mungkin, dan serahkan perbedaan tampilan ke CSS. Jika benar-benar perlu perilaku berbeda, render placeholder/netral dulu sampai client siap.

// Sesudah: satu struktur, responsif via CSS
export function Nav() {
  return (
    <nav className="nav">
      <button className="nav-menu-button">Menu</button>
      <ul className="nav-links">
        <li>Beranda</li>
        <li>Produk</li>
        <li>Kontak</li>
      </ul>
    </nav>
  );
}

Dengan pendekatan ini, SSR tidak perlu menebak viewport. CSS menangani layout, bukan JavaScript saat render awal.

Anti-pattern 4: Skeleton yang berbeda struktur dengan konten akhir

Masalah umum lain adalah skeleton hanya berupa satu blok generik, sementara konten akhir punya banyak elemen, avatar, judul, metadata, dan aksi. Ini tidak selalu menyebabkan mismatch secara langsung, tetapi sering memicu pergeseran layout dan logika render yang sulit dilacak.

Lebih baik buat skeleton yang mencerminkan bentuk akhir komponen:

export function ArticleCardSkeleton() {
  return (
    <article className="card" aria-busy="true">
      <div className="skeleton skeleton-image" />
      <div className="card-body">
        <div className="skeleton skeleton-title" />
        <div className="skeleton skeleton-text" />
        <div className="skeleton skeleton-text short" />
      </div>
    </article>
  );
}

Manfaatnya:

  • struktur DOM lebih dekat ke hasil akhir,
  • layout shift lebih kecil,
  • transisi ke data final lebih mulus,
  • lebih mudah memastikan server dan client merender hal yang sama.

Strategi placeholder deterministik yang praktis

1. Gunakan state awal yang sama di server dan client

Jika data client belum tersedia, mulai dari nilai yang netral dan stabil. Contoh:

  • 'Memuat...' untuk teks,
  • null dengan percabangan render yang konsisten,
  • array kosong hanya jika secara semantik memang aman,
  • skeleton dengan jumlah item tetap.

Hindari state awal yang dihitung dari browser API.

2. Tentukan jumlah skeleton secara tetap

Jika daftar konten belum tersedia, jangan membuat jumlah placeholder berdasarkan perhitungan yang berubah-ubah di client. Gunakan jumlah tetap yang cukup representatif.

export function ProductListSkeleton() {
  return (
    <ul>
      {[0, 1, 2, 3].map((i) => (
        <li key={i}>
          <div className="skeleton skeleton-row" />
        </li>
      ))}
    </ul>
  );
}

3. Gunakan data server sebagai sumber kebenaran render awal

Di Next.js, jika data memang tersedia saat request/build, render data itu di server lalu teruskan ke client. Jangan langsung mengganti dengan sumber client lain sebelum hydration selesai kecuali ada alasan kuat.

Prinsipnya:

  • server mengirim snapshot awal,
  • client meng-hydrate snapshot yang sama,
  • revalidasi atau refresh data terjadi setelah mount.

4. Tunda bagian client-only dengan guard sederhana

Untuk komponen yang bergantung penuh pada browser, gunakan guard mounted.

'use client';

import { useEffect, useState } from 'react';

export function ClientOnly({ children }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    return null;
  }

  return children;
}

Lalu pakai untuk komponen yang memang mustahil stabil saat SSR:

'use client';

export function ThemePreview() {
  return (
    <ClientOnly>
      <RealThemePreview />
    </ClientOnly>
  );
}

Trade-off: konten tidak muncul pada HTML awal. Gunakan ini hanya untuk bagian yang benar-benar client-only, bukan seluruh halaman tanpa alasan.

5. Pisahkan shell stabil dari detail dinamis

Pola yang sering berhasil adalah membuat shell SSR yang stabil: header, kerangka kartu, tinggi area, label umum, dan CTA dasar. Detail yang sensitif terhadap client seperti waktu lokal, status preferensi, atau hasil sensor browser diisi belakangan.

Ini mirip membangun struktur yang kuat di titik beban awal. Bagian yang “menggigit” saat pertama kali UI disentuh browser harus kokoh dan deterministik.

Contoh sebelum/sesudah pada komponen nyata

Sebelum: komponen mudah mismatch

'use client';

import { useState } from 'react';

export function WelcomePanel() {
  const [name] = useState(localStorage.getItem('name') || 'Guest');
  const hour = new Date().getHours();
  const greeting = hour < 12 ? 'Pagi' : 'Siang';
  const compact = window.matchMedia('(max-width: 768px)').matches;

  return (
    <section>
      <h2>Selamat {greeting}, {name}</h2>
      {compact ? <SmallStats /> : <LargeStats />}
    </section>
  );
}

Masalahnya ada di tiga sumber: localStorage, waktu lokal, dan matchMedia. Ketiganya dapat membuat render awal client berbeda dari server.

Sesudah: shell stabil + update setelah mount

'use client';

import { useEffect, useState } from 'react';

export function WelcomePanel() {
  const [name, setName] = useState('Guest');
  const [greeting, setGreeting] = useState('Halo');
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const savedName = window.localStorage.getItem('name');
    if (savedName) setName(savedName);

    const hour = new Date().getHours();
    setGreeting(hour < 12 ? 'Pagi' : 'Siang');
    setReady(true);
  }, []);

  return (
    <section>
      <h2>
        {ready ? `Selamat ${greeting}, ${name}` : 'Memuat profil...'}
      </h2>
      <StatsShell />
    </section>
  );
}

function StatsShell() {
  return (
    <div className="stats-shell">
      <div className="stat-card">...</div>
      <div className="stat-card">...</div>
    </div>
  );
}

Pada versi ini, render awal aman karena semua nilai awal deterministik. Detail personalisasi baru masuk setelah komponen ter-mount. Untuk variasi mobile/desktop, lebih baik biarkan CSS mengatur tampilan .stats-shell daripada mengganti subtree berdasarkan matchMedia.

Kapan memakai client-only guard, kapan cukup skeleton?

Pakai skeleton stabil jika:

  • konten tetap penting untuk hadir pada HTML awal,
  • struktur halaman ingin tetap bisa diindeks atau dirender cepat,
  • hanya sebagian data/detail yang belum diketahui di client,
  • Anda ingin mengurangi layout shift.

Pakai client-only guard jika:

  • komponen sepenuhnya bergantung pada API browser,
  • kontennya tidak masuk akal jika dirender di server,
  • risiko mismatch lebih besar daripada manfaat SSR,
  • komponen bersifat pelengkap, misalnya preview interaktif, chart browser-only tertentu, atau panel yang bergantung kuat pada ukuran viewport real-time.

Sering kali pendekatan terbaik adalah kombinasi keduanya: SSR untuk shell yang kuat, client-only untuk detail yang memang tidak bisa deterministik.

Checklist debugging hydration mismatch

Saat menemukan peringatan hydration, gunakan checklist ini:

  1. Cari sumber non-deterministik
    Periksa apakah ada new Date(), Math.random(), generator ID acak, atau format waktu lokal langsung di render.
  2. Cari akses browser API saat render
    Misalnya window, document, localStorage, sessionStorage, matchMedia, navigator.
  3. Bandingkan struktur DOM server dan client
    Jangan hanya fokus ke teks. Percabangan render yang mengganti elemen div menjadi section atau jumlah item list juga bisa memicu masalah.
  4. Periksa state awal useState
    Pastikan nilai awal tidak dihitung dari environment browser.
  5. Audit conditional rendering
    Jika ada if berdasarkan data yang belum siap, apakah server dan client pasti memilih cabang yang sama pada render pertama?
  6. Pastikan data async punya snapshot awal yang jelas
    Jangan biarkan server merender satu data sementara client langsung memakai data lain dari cache berbeda tanpa transisi yang terkontrol.
  7. Uji tanpa JavaScript dinamis dulu
    Sederhanakan komponen menjadi HTML statis. Jika mismatch hilang, tambahkan kembali logika satu per satu sampai sumbernya ditemukan.
  8. Gunakan placeholder eksplisit
    Jika belum yakin nilai akhirnya, jangan memaksa render final. Placeholder yang konsisten jauh lebih aman.

Kesalahan yang sering dianggap sepele

  • Mengandalkan CSS-in-JS atau class dinamis tanpa memahami output awalnya. Jika class name atau style dihitung berbeda antara server dan client, hasilnya bisa menyimpang.
  • Menyamakan loading dengan empty state. “Belum ada data” bukan hal yang sama dengan “data belum selesai diketahui”.
  • Mengganti seluruh subtree demi satu detail kecil. Misalnya hanya karena label waktu berubah, seluruh kartu ikut dirender dalam cabang berbeda.
  • Terlalu cepat mengambil data client saat mount tanpa mempertahankan snapshot SSR. Revalidasi boleh, tetapi transisinya harus sadar terhadap hydration.

Rangkuman implementasi yang aman

Untuk membangun SSR Skeleton Stabil di React/Next.js, pegang aturan berikut:

  • buat render awal server dan client identik,
  • hindari nilai waktu, random, dan browser API langsung di render,
  • gunakan skeleton atau placeholder deterministik,
  • pertahankan struktur DOM sedekat mungkin dengan hasil akhir,
  • isi detail client setelah mount,
  • pakai client-only guard hanya untuk bagian yang memang tidak layak di-SSR.

UI SSR yang baik bukan yang hanya terlihat bagus setelah interaktif, tetapi yang sudah kuat pada titik beban awal render. Jika bagian awal ini kokoh, hydration berjalan mulus, warning berkurang, dan pengalaman pengguna menjadi lebih stabil.