SSR API-Only sering dipakai ketika backend tidak merender HTML data secara penuh, melainkan hanya menyediakan API. Polanya tampak sederhana: server mengirim shell atau placeholder, lalu browser melakukan fetch dan merender data aktual. Masalahnya, pola ini rawan hydration drift: HTML hasil render awal tidak lagi cocok dengan tree UI yang dibentuk saat klien melakukan hydration.

Intinya, drift terjadi saat server dan klien memulai hydration dari asumsi state yang berbeda. Pada Next.js, Nuxt, maupun SvelteKit, gejalanya mirip: warning mismatch, konten berkedip, node diganti ulang, event handler terasa terlambat, atau hasil akhir berbeda dari render awal. Jika backend Anda API-only atau diskless, solusi utamanya bukan “memaksa SSR untuk terlihat lengkap”, tetapi membuat kontrak state awal yang stabil antara server dan klien.

Apa itu hydration drift pada SSR API-Only

Hydration adalah proses ketika framework di browser mengambil alih HTML hasil server dan menghubungkannya dengan komponen interaktif. Agar aman, hasil render pertama di klien harus sama dengan HTML yang sudah ada. Pada pola API-only, server sering tidak punya data final saat merender, sehingga hanya menampilkan:

  • placeholder, skeleton, atau loading state,
  • shell halaman tanpa data bisnis,
  • nilai default yang nanti diganti setelah fetch di browser.

Ini menjadi masalah jika render pertama di klien tidak lagi menghasilkan placeholder yang sama. Misalnya, server merender “Belum login”, tetapi klien saat hydration sudah membaca cookie/token dan langsung merender “Halo, Budi”. Bagi framework, itu adalah dua tree yang berbeda.

Prinsip penting: pada render pertama di klien, gunakan state yang sama persis dengan state yang dipakai server. Data baru boleh masuk setelah hydration selesai atau lewat mekanisme data SSR yang memang terkoordinasi.

Alur umum yang memicu mismatch

Alur rawan drift

1. Request masuk ke server SSR
2. Server merender shell tanpa data final
3. HTML dikirim ke browser
4. Browser mulai hydration
5. Sebelum/selama hydration, klien fetch API
6. Data datang dan UI klien berubah
7. Tree klien tidak cocok dengan HTML awal

Pada framework modern, hal ini sering tidak tampak pada komponen kecil, tetapi akan jelas pada:

  • header yang bergantung pada status login,
  • list hasil query yang urutannya berubah,
  • format tanggal/angka yang berbeda locale,
  • konten yang bergantung pada waktu saat ini,
  • komponen yang menampilkan fallback berbeda antara server dan klien.

Contoh anti-pattern sederhana

// Pseudocode lintas framework
function Page() {
  const data = isBrowser()
    ? fetchOnClient('/api/feed')
    : null;

  if (!data) {
    return '<div>Memuat...</div>';
  }

  return renderFeed(data);
}

Masalahnya bukan hanya karena klien melakukan fetch, tetapi karena state render pertama bisa berbeda tergantung kapan dan di mana kode dijalankan. Jika fetch klien sangat cepat, komponen bisa langsung merender data sebelum hydration selesai, sedangkan HTML server masih berisi fallback.

Penyebab umum hydration drift di Next.js, Nuxt, dan SvelteKit

Framework berbeda pada detail API, tetapi sumber mismatch biasanya sama.

1. Waktu render berbeda

Nilai seperti Date.now(), new Date(), hitung mundur, atau label relatif seperti “baru saja” hampir selalu berisiko bila dihitung saat render.

// Anti-pattern
const label = formatRelativeTime(new Date());

Server dan klien hampir pasti menjalankannya pada waktu berbeda. Perbedaan beberapa detik saja dapat mengubah output string.

Cara aman: serialisasikan timestamp dari server sebagai input tetap, lalu format ulang di klien setelah hydration jika memang perlu pembaruan real-time.

2. Locale dan timezone berbeda

Server bisa berjalan di timezone UTC, sedangkan browser memakai timezone lokal pengguna. Format tanggal, angka, mata uang, dan urutan teks bisa berubah.

// Anti-pattern
const text = new Intl.DateTimeFormat().format(new Date(article.publishedAt));

Jika server dan browser memakai locale/timezone berbeda, string hasilnya bisa tidak sama. Ini umum pada konten berita, transaksi, dan dashboard.

Cara aman: gunakan locale/timezone eksplisit untuk output SSR, atau render bentuk netral di server lalu ubah ke format lokal setelah hydration.

3. Random value atau ID tidak stabil

Pemanggilan Math.random(), pembuatan ID acak saat render, atau pemilihan urutan elemen secara acak akan menghasilkan tree berbeda.

// Anti-pattern
const variant = Math.random() > 0.5 ? 'A' : 'B';

Cara aman: hasilkan nilai sekali di server, serialisasikan ke state awal, dan pakai ulang nilai yang sama di klien.

4. Auth state tidak sinkron

Pada backend API-only, status autentikasi sering baru diketahui di browser lewat token, cookie yang dibaca klien, atau panggilan /me. Jika server SSR tidak memakai informasi yang sama, header, menu, atau konten terproteksi mudah drift.

Contoh klasik:

  • server merender tombol Masuk,
  • klien membaca token dan langsung merender avatar pengguna,
  • hydration mismatch terjadi pada area navigasi.

Cara aman: jika status auth memengaruhi HTML awal, server harus mengetahui status itu juga, atau render area tersebut sebagai shell netral yang identik di server dan render pertama klien.

5. Cache race

Server dan klien bisa mengambil data dari lapisan cache berbeda dengan umur data berbeda: CDN, cache API, edge cache, memory cache di browser, atau stale-while-revalidate. Akibatnya, list item, jumlah notifikasi, atau harga bisa berbeda saat hydration.

Cara aman: gunakan snapshot data awal yang diserialisasikan dari hasil SSR, lalu biarkan revalidasi berjalan setelah hydration, bukan sebelum tree awal stabil.

6. Fallback loading yang tidak konsisten

Suspense, placeholder, dan deferred data membantu performa, tetapi fallback harus konsisten. Drift sering muncul jika server merender skeleton A, sedangkan klien pada render pertama langsung memiliki data dan merender konten final, atau malah memakai fallback B yang berbeda struktur.

Strategi utama: stabilkan state awal dan kontrak data SSR

Solusi paling praktis untuk SSR API-Only adalah memperlakukan render awal sebagai kontrak antara server dan klien. Server tidak harus selalu mengirim HTML lengkap berisi semua data, tetapi ia harus mengirim state awal yang cukup agar render pertama di browser identik.

1. Kirim snapshot state awal bersama HTML

Jika server sudah tahu sebagian data atau keputusan UI, serialisasikan ke payload awal. Klien harus membaca payload ini lebih dulu sebelum melakukan fetch tambahan.

<script type="application/json" id="__INITIAL_STATE__">
{
  "viewer": null,
  "feed": {
    "status": "loading",
    "items": []
  },
  "renderedAt": "2026-08-30T10:00:00Z",
  "locale": "id-ID",
  "timezone": "UTC"
}
</script>

Poin pentingnya bukan format persisnya, melainkan aturan ini:

  • server dan klien membaca sumber state awal yang sama,
  • render pertama klien wajib memakai snapshot itu,
  • fetch ulang boleh dilakukan setelah mount/hydration atau melalui revalidasi terkontrol.

2. Bedakan data render-kritis dan data pasca-hydration

Tidak semua data layak dipaksa stabil sejak SSR. Bagi data menjadi dua kategori:

  • Render-kritis: data yang memengaruhi struktur HTML awal, misalnya auth state, jumlah item list, judul, urutan menu, empty state.
  • Pasca-hydration: data yang boleh menyusul, misalnya metrik live, badge notifikasi real-time, rekomendasi tambahan.

Jika data mengubah struktur DOM utama, usahakan tersedia dalam kontrak state awal. Jika tidak, pertimbangkan merender placeholder stabil sampai hydration selesai.

3. Gunakan status eksplisit, bukan inferensi dari null

Kesalahan umum adalah menganggap null berarti “belum dimuat”, padahal kadang berarti “memang tidak ada data”. Lebih aman memakai status eksplisit.

// Lebih aman
{
  "feed": {
    "status": "loading", // loading | ready | empty | error
    "items": []
  }
}

Dengan begitu, server dan klien tidak menebak-nebak cabang render yang harus dipakai.

4. Tetapkan input deterministik untuk output yang sensitif

Untuk waktu, locale, timezone, eksperimen A/B, dan ID turunan, gunakan input yang sudah dipatok dari server. Hindari menghitungnya ulang secara implisit saat render awal di klien.

Contoh alur SSR + fetch klien yang aman

Berikut pola generik yang cocok di berbagai framework.

Langkah arsitektur

  1. Server SSR menerima request dan menentukan konteks minimum: user, locale, timezone, feature flag, parameter route.
  2. Server mengambil data yang benar-benar diperlukan untuk struktur HTML awal, atau minimal menentukan statusnya secara eksplisit.
  3. Server merender HTML dan menyisipkan snapshot state awal.
  4. Klien melakukan hydration menggunakan snapshot yang sama.
  5. Setelah hydration, klien melakukan revalidasi atau fetch data tambahan.

Pseudocode implementasi

// Server-side pseudo
const initialState = {
  viewer: request.user ? {
    id: request.user.id,
    name: request.user.name
  } : null,
  feed: {
    status: 'loading',
    items: []
  },
  locale: request.locale || 'id-ID',
  timezone: 'UTC'
};

return renderHtml({ initialState });
// Client-side pseudo
const initialState = readInitialState();
const app = createApp({ initialState });
hydrate(app);

onHydrated(async () => {
  const feed = await fetch('/api/feed').then(r => r.json());
  app.update({
    feed: {
      status: feed.items.length ? 'ready' : 'empty',
      items: feed.items
    }
  });
});

Mengapa pola ini aman?

  • Render pertama di server dan klien sama-sama memakai feed.status = 'loading'.
  • Data klien tidak mengganggu proses hydration karena baru diterapkan sesudahnya.
  • Status auth, locale, dan input penting lain sudah dipatok sejak awal.

Anti-pattern yang sering memicu mismatch

Fetch klien di render path pertama

Jika logika render pertama dapat langsung berubah karena data klien datang terlalu cepat, mismatch mudah terjadi. Hindari memulai cabang render yang berbeda sebelum hydration stabil.

Membaca environment browser saat render SSR-sensitive

Nilai seperti window.location, localStorage, ukuran viewport, preferensi media query, atau state cookie yang hanya dibaca di klien sebaiknya tidak menentukan struktur HTML awal tanpa fallback netral.

Memakai key list yang tidak stabil

Jika key item bergantung pada indeks sementara urutan data bisa berubah antara server dan klien, node dapat tertukar atau diganti ulang. Selalu pakai ID stabil dari data.

Menampilkan waktu relatif langsung di SSR

String seperti “5 menit lalu” sangat mudah berbeda. Untuk HTML awal, lebih aman gunakan timestamp absolut atau string yang dipatok dari server.

Fallback berbeda struktur

Skeleton server dengan tiga item placeholder, tetapi klien merender spinner tunggal pada render pertama, adalah mismatch struktural. Bila memakai fallback, samakan markup dasarnya.

Suspense dan deferred data: berguna, tetapi harus disiplin

Suspense atau deferred data dapat mengurangi waktu tunggu data penuh dan mempercepat TTFB, tetapi ia bukan obat otomatis untuk hydration drift. Yang penting adalah kesepakatan kapan fallback boleh diganti.

Prinsip aman saat memakai suspense

  • Pastikan fallback yang dirender di server sama dengan fallback pada render pertama klien.
  • Jangan biarkan promise/data source di klien menyelesaikan cabang render berbeda sebelum hydration siap, kecuali framework menjamin koordinasinya.
  • Pisahkan boundary yang aman untuk berubah cepat dari area yang sensitif terhadap mismatch, seperti header, navigasi, atau konten utama di atas lipatan.

Kapan deferred data cocok

Deferred data cocok untuk:

  • sidebar sekunder,
  • rekomendasi tambahan,
  • widget non-kritis,
  • data besar yang tidak menentukan struktur inti halaman.

Kurang cocok untuk:

  • auth banner utama,
  • judul/konten primer halaman,
  • empty state vs list state,
  • informasi legal atau transaksi yang harus akurat sejak render awal.

Panduan praktis per sumber masalah

Waktu dan tanggal

  • Serialisasikan timestamp dari server.
  • Gunakan timezone eksplisit untuk output SSR.
  • Jika butuh format lokal pengguna, render placeholder netral atau timestamp absolut dulu, lalu tingkatkan setelah hydration.

Locale

  • Tentukan locale dari request jika memungkinkan.
  • Jangan mengandalkan default environment server.
  • Pastikan formatter klien memakai locale yang sama pada render pertama.

Auth state

  • Jika SSR menampilkan UI berbeda untuk login/logout, server harus tahu status auth.
  • Jika tidak memungkinkan, render shell netral yang sama di server dan klien sampai status auth terkonfirmasi.
  • Hindari mengganti struktur navigasi sebelum hydration selesai.

Cache race

  • Jangan abaikan snapshot SSR saat klien punya cache sendiri.
  • Gunakan initial data sebagai sumber kebenaran render pertama.
  • Lakukan revalidasi sesudah hydration, bukan mengganti tree sejak awal.

Random/ID

  • Jangan buat nilai acak saat render.
  • Precompute di server dan serialisasikan, atau buat hanya setelah mount bila tidak memengaruhi HTML awal.

Checklist debugging hydration drift

Saat mismatch muncul, cari perbedaan input render, bukan hanya output akhir.

  1. Bandingkan state awal server dan klien. Log payload yang diserialisasikan dan payload yang dipakai klien sebelum hydration.
  2. Cari nilai non-deterministik. Audit penggunaan waktu saat ini, random, locale default, timezone, viewport, dan API browser.
  3. Periksa auth path. Apakah server dan klien sama-sama tahu user login atau tidak?
  4. Telusuri fetch yang terlalu dini. Apakah data klien masuk sebelum hydration selesai?
  5. Periksa fallback. Apakah skeleton, spinner, dan empty state identik antara SSR dan render pertama klien?
  6. Validasi key list. Pastikan key stabil dan urutan data tidak berubah diam-diam.
  7. Matikan revalidasi sementara. Jika mismatch hilang, berarti masalah ada pada race antara snapshot awal dan fetch klien.
  8. Uji dengan timezone/locale berbeda. Jalankan server dan browser dengan konfigurasi berbeda untuk memancing mismatch yang tersembunyi.
  9. Isolasi boundary. Nonaktifkan komponen satu per satu untuk menemukan area yang menghasilkan tree berbeda.

Kapan lebih baik memilih CSR penuh dibanding SSR parsial

Tidak semua halaman perlu SSR. Pada arsitektur API-only, SSR parsial justru bisa menambah kompleksitas jika data kritis tidak tersedia secara andal saat request server.

Pilih CSR penuh jika sebagian besar benar berikut ini:

  • halaman sangat bergantung pada state user yang hanya diketahui di browser,
  • konten utama cepat berubah dan hampir pasti stale saat SSR,
  • nilai SEO dari HTML awal rendah atau tidak relevan,
  • UI utama sebenarnya adalah dashboard aplikasi setelah login,
  • Anda tidak dapat menjamin kontrak state awal yang konsisten.

Pilih SSR parsial jika:

  • ada struktur halaman yang perlu tampil cepat dan stabil,
  • SEO atau preview konten tetap penting,
  • Anda bisa menentukan subset data render-kritis di server,
  • komponen dinamis bisa ditunda sampai setelah hydration.

Aturan praktis: jika HTML awal tidak bisa dibuat benar atau setidaknya stabil, jangan memaksa SSR. CSR penuh sering lebih jujur, lebih sederhana, dan lebih mudah di-debug dibanding SSR yang terus drift.

Kesimpulan

Masalah utama pada SSR API-Only: Mencegah Hydration Drift pada Data Tanpa HTML Awal bukan sekadar “server belum punya data”, melainkan server dan klien tidak berbagi state awal yang sama. Penyebab umumnya konsisten di Next.js, Nuxt, dan SvelteKit: waktu render berbeda, locale/timezone, random value, auth state, cache race, dan fallback loading yang tidak sinkron.

Solusi yang paling dapat diandalkan adalah membuat kontrak data SSR: tentukan data render-kritis, serialisasikan snapshot state awal, pastikan render pertama klien memakai snapshot itu, lalu lakukan fetch atau revalidasi setelah hydration. Jika itu tidak realistis untuk halaman tertentu, CSR penuh biasanya merupakan pilihan yang lebih aman daripada SSR parsial yang tidak deterministik.