GraphQL SSR render mismatch biasanya terjadi ketika server merender HTML dengan asumsi tertentu, tetapi setelah hydration di browser, aplikasi menghitung ulang state atau variabel query dengan nilai yang berbeda. Akibatnya, markup berubah, query GraphQL dijalankan ulang dengan parameter baru, dan UI tampak "lompat", menampilkan warning hydration, atau menunjukkan data yang berbeda sesaat setelah halaman dimuat.

Masalah ini umum di Next.js dan stack SSR serupa, tetapi akar persoalannya tidak spesifik ke satu framework. Sumber utamanya hampir selalu sama: ada data yang dipakai saat render awal namun hanya tersedia atau bernilai berbeda di klien, misalnya localStorage, cookie yang tidak ikut dibaca server, timezone browser, feature flag berbasis storage, atau variabel GraphQL yang dihitung dari API browser seperti window dan navigator.

Kenapa render mismatch terjadi pada SSR GraphQL

Pada SSR, alur umumnya seperti ini:

  1. Server menerima request.
  2. Server menyiapkan context, membaca cookie/header yang tersedia, lalu menjalankan query GraphQL untuk menghasilkan HTML awal.
  3. Cache hasil query di-serialisasi ke HTML agar klien bisa melakukan hydration tanpa fetch ulang penuh.
  4. Browser memuat HTML, JavaScript aktif, lalu komponen dirender ulang di klien.
  5. Jika variabel query atau state awal di klien berbeda dari server, klien dapat menjalankan query berbeda atau merender cabang UI yang berbeda.

Di titik inilah mismatch muncul. Server mungkin merender daftar produk untuk region ID, tetapi klien membaca localStorage.region = SG lalu mengganti variabel query. HTML awal dan hasil render klien tidak lagi identik.

Gejala yang biasanya terlihat

  • Warning hydration seperti teks atau struktur DOM tidak cocok.
  • UI terlihat benar sepersekian detik lalu berubah setelah JavaScript aktif.
  • Query GraphQL berjalan dua kali dengan variabel berbeda.
  • Cache GraphQL tampak "diabaikan" karena klien menganggap permintaan baru memiliki key yang berbeda.
  • Data yang tampil di server benar, tetapi berubah setelah browser selesai boot.

Sumber variabel klien yang paling sering memicu mismatch

1. localStorage atau sessionStorage

Ini penyebab klasik. Server tidak punya akses ke localStorage, sehingga saat SSR Anda biasanya memakai fallback. Setelah hydration, klien membaca nilai sebenarnya dan mengubah query atau cabang render.

Contoh kasus:

  • Preferensi mata uang disimpan di localStorage.
  • Filter pencarian terakhir disimpan di browser.
  • Region toko dipilih pengguna dan hanya tersedia di storage klien.

2. Cookie yang dibaca tidak konsisten

Cookie sebenarnya bisa aman untuk SSR jika server dan klien membaca sumber yang sama. Masalah muncul ketika:

  • Server tidak meneruskan cookie ke layer GraphQL.
  • Klien membaca cookie terbaru yang tidak terlihat saat request SSR sebelumnya.
  • Nilai cookie diubah oleh JavaScript sebelum hydration selesai.

Secara praktis, cookie lebih cocok daripada localStorage untuk state yang harus memengaruhi SSR karena request server memang bisa membawanya. Tetapi implementasinya harus konsisten.

3. Timezone dan locale browser

Server sering merender tanggal dengan timezone default server atau timezone dari header yang terbatas. Browser lalu memformat ulang berdasarkan timezone lokal pengguna. Jika string tanggal langsung dirender ke HTML, hasilnya bisa berbeda meski datanya sama.

Contoh:

  • Server merender 08:00 UTC.
  • Klien memformat menjadi 15.00 WIB.
  • Teks di DOM berubah dan hydration warning muncul.

4. Feature flag yang hanya diketahui di klien

Jika feature flag dihitung dari SDK browser, nilai saat SSR bisa unknown atau fallback. Begitu SDK selesai inisialisasi di klien, komponen berpindah ke layout lain atau query tambahan dijalankan.

Masalahnya bukan hanya perubahan tampilan, tetapi juga perubahan variabel GraphQL. Misalnya flag newPricing mengubah field yang diminta atau endpoint resolusi harga.

5. Variabel query dari API browser

Contoh umum:

  • navigator.language untuk locale.
  • window.innerWidth untuk mode mobile/desktop.
  • window.location yang dipakai di luar mekanisme routing SSR.
  • Status login yang dihitung dari token di storage browser.

Semua nilai ini berisiko menyebabkan hasil query atau hasil render awal berubah setelah hydration.

Bagaimana markup berubah walau query server sudah benar

Banyak tim mengira jika SSR query berhasil dan cache awal sudah diinjeksikan, maka hydration pasti stabil. Kenyataannya tidak selalu demikian, karena cache GraphQL hanya stabil jika query, variabel, dan kebijakan pembacaan data sama antara server dan klien.

Contoh alur masalah:

  1. Server menjalankan query GetProducts(region: "ID", currency: "IDR").
  2. HTML dan cache awal dikirim ke browser.
  3. Saat hydration, komponen membaca localStorage.currency = "USD".
  4. Hook GraphQL di klien kini punya variabel region: "ID", currency: "USD".
  5. Karena key query berbeda, cache awal tidak cocok penuh.
  6. Klien melakukan fetch ulang atau merender state berbeda.
  7. Markup berubah: harga, urutan item, badge promosi, atau bahkan jumlah elemen.

Jadi akar mismatch bukan semata "SSR gagal", melainkan kontrak data flow antara server dan klien tidak identik pada render pertama.

Pola salah yang sering muncul

Membaca localStorage saat inisialisasi render

function ProductList() {
  const currency = typeof window !== 'undefined'
    ? localStorage.getItem('currency') || 'IDR'
    : 'IDR';

  const { data } = useProductsQuery({
    variables: { currency }
  });

  return <ProductGrid products={data?.products || []} />;
}

Kode di atas tampak aman karena ada pengecekan window, tetapi tetap berisiko mismatch. Di server, currency bernilai IDR. Di klien, bisa jadi USD. Query pertama di klien berbeda dari query server.

Memformat data presentasional yang sensitif timezone langsung saat SSR

function OrderTime({ createdAt }) {
  const text = new Date(createdAt).toLocaleString();
  return <span>{text}</span>;
}

Jika environment server dan browser punya locale/timezone berbeda, string yang dihasilkan bisa berbeda walau createdAt-nya sama.

Mengganti variabel query dari effect tanpa sinkronisasi state awal

function Feed() {
  const [region, setRegion] = useState('ID');

  useEffect(() => {
    const saved = localStorage.getItem('region');
    if (saved) setRegion(saved);
  }, []);

  const { data } = useFeedQuery({ variables: { region } });
  return <FeedList items={data?.items || []} />;
}

Ini tidak selalu memunculkan warning hydration, tetapi sering memunculkan UI yang "berkedip": server merender region default, lalu klien mengganti region dan me-fetch ulang. Jika layout berubah, mismatch bisa terlihat jelas.

Pola aman untuk mencegah GraphQL SSR render mismatch

1. Jadikan server sebagai sumber kebenaran untuk render pertama

Jika suatu variabel memengaruhi query SSR, usahakan nilainya tersedia juga saat request server. Cara paling umum adalah memindahkan state penting dari localStorage ke cookie atau parameter URL yang ikut terkirim ke server.

Contoh pendekatan:

  • Region aktif disimpan di cookie.
  • Locale diambil dari route atau header yang dipetakan secara eksplisit.
  • Feature flag awal di-resolve di server dan diserialisasi ke halaman.

Keuntungan pendekatan ini adalah query server dan klien dapat memakai nilai awal yang sama.

2. Serialisasi initial state yang benar-benar dipakai klien

Jangan hanya mengirim cache GraphQL. Kirim juga input yang dipakai untuk membangun query: locale, region, user segment, feature flags, currency, dan preference lain yang memengaruhi render awal.

Prinsipnya:

  • Server menentukan initialRequestState.
  • HTML memuat state itu.
  • Klien membaca state yang sama saat hydration pertama.
  • Pembacaan dari storage browser dilakukan setelah hydration, dan hanya jika memang boleh menyebabkan update.

Dengan begitu, render pertama di klien tidak menghitung ulang variabel dari sumber lain.

3. Skip query sampai client-ready jika variabel hanya ada di browser

Jika sebuah query memang tidak bisa dihitung dengan benar di server, lebih aman untuk tidak memaksakan SSR pada query tersebut. Render placeholder yang stabil, lalu jalankan query setelah klien siap.

function ClientOnlyRecommendations() {
  const [ready, setReady] = useState(false);
  const [segment, setSegment] = useState(null);

  useEffect(() => {
    setSegment(localStorage.getItem('segment'));
    setReady(true);
  }, []);

  const { data, loading } = useRecommendationsQuery({
    variables: { segment },
    skip: !ready || !segment
  });

  if (!ready) return <RecommendationsSkeleton />;
  if (loading) return <RecommendationsSkeleton />;
  return <RecommendationList items={data?.items || []} />;
}

Trade-off-nya jelas: Anda mengurangi manfaat SSR untuk bagian itu. Tetapi Anda juga menghilangkan mismatch dan perilaku UI yang sulit diprediksi.

4. Pisahkan data SSR-kritis dan data personalisasi-klien

Ini pola arsitektur yang sering paling stabil. Render server hanya memuat data yang benar-benar bisa ditentukan dari request server. Personalisasi yang bergantung pada browser dimuat setelah hydration sebagai lapisan kedua.

Misalnya:

  • SSR: daftar produk umum, kategori, stok dasar.
  • Klien: rekomendasi personal, urutan favorit, promosi berdasarkan segment local-only.

Dengan pemisahan ini, struktur markup awal lebih stabil dan cache GraphQL lebih mudah dipahami.

5. Tunda formatting yang non-deterministik

Untuk tanggal, timezone, atau string locale-sensitive, hindari menghasilkan string final yang berbeda antara server dan klien jika kontennya harus di-hydrate identik.

Pilihan aman:

  • Render format netral di server lalu tingkatkan di klien.
  • Render placeholder sampai locale/timezone diketahui.
  • Pastikan server menerima timezone eksplisit dari request sebelumnya jika memang tersedia.

Strategi sinkronisasi initial state yang stabil

Satu objek konteks request

Daripada setiap komponen membaca sumbernya sendiri, buat satu objek konteks request yang dibangun di server dan dipakai ulang di klien. Isinya bisa meliputi:

  • locale
  • region
  • currency
  • timezone jika tersedia
  • feature flags awal
  • identitas user atau status anonymous/authenticated

Semua query GraphQL yang memengaruhi SSR sebaiknya mengambil variabel dari objek ini, bukan langsung dari API browser.

const initialRequestState = {
  locale: 'id-ID',
  region: 'ID',
  currency: 'IDR',
  flags: { newPricing: false }
};

function ProductsPage({ initialRequestState }) {
  const requestState = useInitialRequestState(initialRequestState);

  const { data } = useProductsQuery({
    variables: {
      region: requestState.region,
      currency: requestState.currency,
      locale: requestState.locale
    }
  });

  return <ProductGrid products={data?.products || []} />;
}

Kemudian, jika Anda ingin menyinkronkan preferensi browser setelah hydration, lakukan sebagai update eksplisit yang Anda pahami dampaknya, bukan sebagai bagian dari render awal.

Prioritaskan urutan sumber state

Tentukan prioritas yang konsisten, misalnya:

  1. Parameter URL
  2. Cookie request
  3. Server default
  4. Storage browser setelah hydration

Dokumentasikan aturan ini. Banyak mismatch muncul bukan karena bug teknis murni, tetapi karena tim frontend dan backend punya asumsi sumber kebenaran yang berbeda.

Checklist debugging: hydration, cache, atau resolver?

Saat bug muncul, jangan langsung menyalahkan SSR atau GraphQL client. Pecah diagnosis menjadi tiga area.

A. Cek apakah ini benar-benar bug hydration

  • Bandingkan HTML server dengan hasil render pertama di klien sebelum efek asinkron selesai.
  • Lihat apakah warning menyebut teks, atribut, atau struktur node yang tidak cocok.
  • Periksa komponen yang membaca window, document, localStorage, navigator, atau waktu sistem saat render.
  • Nonaktifkan sementara personalisasi klien untuk melihat apakah mismatch hilang.

B. Cek key query dan cache GraphQL

  • Log query dan variabel saat SSR.
  • Log query dan variabel saat render klien pertama.
  • Pastikan serialisasi cache dari server benar-benar dipulihkan di klien.
  • Periksa apakah field cache key bergantung pada variabel yang berubah setelah hydration.
  • Lihat apakah fetch policy menyebabkan re-fetch walau data awal tersedia.

Jika query dan variabel berbeda, itu biasanya bukan bug cache. Cache hanya bereaksi pada input yang berubah.

C. Cek resolver atau backend GraphQL

  • Jalankan query yang sama dengan variabel yang sama di server dan klien, lalu bandingkan respons.
  • Pastikan backend tidak bergantung diam-diam pada header yang hanya ada di satu sisi.
  • Periksa apakah ada resolver yang membaca locale, auth, region, atau experiment bucket dari context yang tidak konsisten.
  • Lihat apakah ada data yang memang berubah cepat di backend sehingga hasil SSR dan hasil klien berbeda secara natural.

Jika HTML server dan render klien memakai variabel sama, tetapi respons GraphQL berbeda, kemungkinan masalah ada di resolver, auth context, atau dependensi backend lain.

Debugging praktis yang sangat membantu

  • Tambahkan log terstruktur untuk variables, headers, dan request context di SSR dan klien.
  • Tampilkan sementara debug panel yang memuat region, locale, currency, timezone, dan source-nya.
  • Bekukan sumber variabel satu per satu: paksa region tetap, matikan flag, abaikan localStorage, lalu lihat kapan mismatch hilang.
  • Bandingkan snapshot HTML sebelum dan sesudah hydration pada komponen yang dicurigai.

Kapan harus memakai SSR penuh, partial, atau client-only

Gunakan SSR penuh jika

  • Variabel query dapat ditentukan dari request server secara konsisten.
  • Konten penting untuk SEO atau waktu tampil awal.
  • Markup awal harus stabil dan dapat di-cache dengan baik.

Gunakan SSR + enhancement klien jika

  • Ada data umum yang stabil untuk dirender di server.
  • Personalisasi tambahan bergantung pada state browser.
  • Anda ingin menjaga HTML awal konsisten tanpa kehilangan semua manfaat interaktivitas.

Gunakan client-only untuk bagian tertentu jika

  • Variabel inti hanya tersedia di browser.
  • Konten sangat personal dan tidak penting untuk SSR.
  • Biaya menjaga konsistensi SSR lebih tinggi daripada manfaatnya.

Rekomendasi arsitektur data flow yang stabil

Untuk mencegah GraphQL SSR render mismatch secara sistematis, gunakan prinsip berikut:

  1. Tentukan request state tunggal di server untuk semua variabel yang memengaruhi render awal.
  2. Serialisasi request state dan cache GraphQL ke HTML, lalu gunakan kembali tanpa perhitungan ulang saat hydration pertama.
  3. Jangan baca variabel browser saat render SSR-kritis. Jika harus dibaca, lakukan setelah klien siap.
  4. Pisahkan konten deterministik dan personalisasi browser-only agar struktur markup awal tetap stabil.
  5. Gunakan cookie atau URL untuk state yang harus memengaruhi SSR, bukan hanya localStorage.
  6. Audit semua sumber variabel query agar server dan klien memiliki urutan prioritas yang sama.

Dengan arsitektur seperti ini, Anda bukan hanya menghilangkan warning hydration, tetapi juga membuat perilaku cache, re-fetch, dan debugging GraphQL jauh lebih dapat diprediksi. Itu penting karena masalah render mismatch jarang berdiri sendiri; biasanya ia menandakan aliran data awal yang belum memiliki sumber kebenaran yang jelas.

Aturan praktisnya sederhana: jika sebuah nilai mengubah query atau markup awal, server dan klien harus melihat nilai awal yang sama. Jika itu tidak mungkin, jangan paksa SSR pada bagian tersebut.