SSR aman bukan hanya soal performa dan SEO, tetapi juga soal memastikan HTML awal tidak memuat data yang salah, tidak stabil, atau bahkan sensitif. Saat server merender halaman, framework biasanya menyisipkan payload awal agar klien bisa melakukan hydration. Jika payload ini berisi metadata file, state turunan dari environment, isi daftar file dinamis, atau nilai yang berbeda ketika komponen dirender ulang di browser, hasilnya bisa dua: hydration mismatch dan kebocoran informasi.

Masalah ini makin relevan di ekosistem tooling modern. Sebagai konteks risiko, ada diskusi publik seperti isu OpenAI Codex tentang file sensitif yang sulit dikecualikan dari alur kerja tooling otomatis. Poin utamanya bukan beritanya, melainkan pelajaran arsitekturalnya: jika tool, build step, atau lapisan SSR terlalu mudah membawa konteks file dan state internal, maka data yang semestinya privat bisa ikut masuk ke output yang dilihat klien.

Kenapa hydration bisa bocor sekaligus mismatch?

Pada SSR, server menghasilkan HTML awal. Agar framework di browser dapat melanjutkan aplikasi tanpa merender dari nol, state awal biasanya diserialisasi ke dalam dokumen, sering kali dalam script JSON atau objek global. Di sinilah dua kelas bug sering bertemu:

  • Kebocoran data: nilai yang seharusnya hanya ada di server ikut terserialisasi ke HTML.
  • Hydration mismatch: nilai yang dipakai server berbeda dengan yang dihitung ulang di klien, sehingga DOM hasil hydration tidak cocok dengan HTML awal.

Keduanya sering berasal dari sumber yang sama: state yang tidak deterministik, terlalu luas, atau tidak dibatasi secara eksplisit saat dikirim ke klien.

Gejala yang sering muncul

  • Peringatan seperti Text content does not match server-rendered HTML.
  • Elemen UI berubah sesaat setelah halaman dimuat.
  • Komponen daftar tampil urutannya berbeda antara render server dan klien.
  • Data yang tidak seharusnya terlihat muncul di view-source, DevTools, atau payload hydration.
  • Bug yang hanya muncul di production karena environment server berbeda dari browser lokal.

Jika Anda melihat mismatch hydration sekaligus menemukan payload SSR yang besar atau “terlalu kaya konteks”, perlakukan itu sebagai sinyal keamanan juga, bukan sekadar bug tampilan.

Sumber masalah yang paling umum

1. State turunan dari environment server

Kesalahan klasik adalah menghitung state UI dari variabel environment, path lokal, hostname internal, atau konfigurasi rahasia di server lalu mengoper seluruh objeknya ke klien.

Contoh buruk:

// Server-side props atau loader pseudo-code
return {
  props: {
    config: process.env,
    featureFlags: deriveFlagsFromEnv(process.env),
  }
}

Meskipun niatnya hanya ingin mengirim beberapa flag, pola ini mudah berkembang menjadi payload yang membawa terlalu banyak data. Selain berisiko bocor, nilai di klien juga bisa berbeda jika flag dihitung ulang dari kondisi browser.

2. Daftar file dinamis dan metadata internal

Beberapa aplikasi atau tool membaca filesystem saat render: daftar dokumen, lampiran, cache artefak, atau metadata hasil scanning direktori. Jika hasil ini masuk ke state SSR tanpa sanitasi, HTML awal bisa memuat nama file, path, ukuran, waktu modifikasi, atau marker internal yang tidak seharusnya terlihat.

Ini relevan dengan risiko tooling modern: semakin banyak pipeline otomatis yang menginspeksi workspace, semakin penting memisahkan data operasional dari data presentasi.

3. localStorage, sessionStorage, dan browser API lain

Server tidak punya akses ke window, localStorage, atau preferensi runtime browser. Jika komponen membaca state tersebut saat render, hasil server dan klien hampir pasti berbeda.

Contoh umum:

  • Tema gelap/terang dari localStorage
  • Bahasa dari navigator.language
  • Lebar layar dari window.innerWidth
  • Status login yang disimpan klien tetapi belum tervalidasi di server

4. Waktu, angka acak, dan identitas yang tidak stabil

Menggunakan Date.now(), new Date(), Math.random(), atau generator ID saat render akan memicu perbedaan alami antara server dan klien.

function Badge() {
  return <span>Render pada {Date.now()}</span>
}

Kode di atas hampir pasti mismatch. Selain itu, jika timestamp atau token debugging ikut terserialisasi, Anda memperbesar payload tanpa manfaat nyata.

5. Perbedaan sumber data server dan klien

Server bisa merender dengan cookie tertentu, header internal, IP, locale reverse proxy, atau data sesi yang tidak identik dengan kondisi saat browser melakukan hydration. Kalau logika tampilan bergantung langsung pada sumber-sumber ini tanpa kontrak data yang stabil, mismatch mudah terjadi.

Pola pencegahan yang benar-benar praktis

1. Gunakan server-safe serialization, bukan menyiram seluruh state

Prinsip utamanya: serialisasikan hanya data yang memang dibutuhkan untuk tampilan awal. Jangan kirim objek mentah dari server, hasil query penuh, isi environment, atau model domain lengkap jika komponen hanya butuh dua atau tiga field.

Lebih aman membuat DTO atau mapper eksplisit:

// Contoh umum: mapper aman sebelum dikirim ke klien
function toPublicFileItem(file) {
  return {
    id: file.id,
    name: file.displayName,
    sizeLabel: formatSize(file.size),
  }
}

return {
  props: {
    files: files.map(toPublicFileItem),
  }
}

Mengapa ini bekerja? Karena Anda memotong permukaan kebocoran di titik paling awal. Path absolut, hash internal, owner, lokasi storage, atau metadata lain tidak pernah menjadi bagian dari kontrak klien.

2. Pisahkan state privat server dari state presentasi klien

Jangan gunakan satu objek besar sebagai sumber kebenaran untuk server dan klien sekaligus. Pisahkan menjadi:

  • Private server state: token, path, env, header internal, hasil audit, metadata file lengkap.
  • Public view model: field yang memang dibutuhkan HTML awal dan interaksi klien.

Di Next.js, Nuxt, atau Inertia, prinsip ini tetap sama meskipun nama API berbeda. Data yang Anda kembalikan dari fungsi server-side harus dianggap sebagai public surface.

3. Terapkan whitelist props, bukan blacklist

Blacklist mudah gagal karena Anda harus mengingat semua field sensitif. Whitelist memaksa Anda mendeklarasikan field yang aman satu per satu.

// Buruk: menghapus beberapa field sensitif setelah fakta
const payload = { ...user, ...fileMeta }
delete payload.secret
delete payload.absolutePath

// Lebih baik: whitelist eksplisit
const payload = {
  user: {
    id: user.id,
    name: user.name,
  },
  file: {
    name: file.displayName,
    ext: file.ext,
  },
}

Whitelist juga membantu review kode. Saat ada field baru di model server, ia tidak otomatis bocor ke klien.

4. Gunakan placeholder yang stabil untuk data berbasis browser

Jika nilai baru bisa diketahui di browser, render placeholder yang sama di server dan klien awal, lalu isi nilainya setelah mount.

Contoh tema atau preferensi lokal:

import { useEffect, useState } from 'react'

function ThemeLabel() {
  const [theme, setTheme] = useState('default')

  useEffect(() => {
    const saved = window.localStorage.getItem('theme')
    if (saved) setTheme(saved)
  }, [])

  return <span>Tema: {theme}</span>
}

Mengapa ini lebih aman? Karena render awal konsisten. Anda menunda pembacaan browser API sampai fase klien, sehingga hydration tidak membandingkan dua nilai berbeda.

Trade-off-nya: mungkin ada sedikit perubahan UI setelah mount. Jika itu mengganggu, Anda bisa menyamakan nilai awal lewat cookie yang divalidasi server, bukan membaca localStorage saat render.

5. Guard semua browser API

Untuk kode universal, hindari akses browser API di jalur render server. Gunakan guard dan pindahkan logika ke lifecycle klien.

function getClientLocale() {
  if (typeof window === 'undefined') return null
  return window.navigator.language
}

Namun guard saja tidak selalu cukup. Jika hasil null di server lalu string locale di klien dipakai langsung untuk merender teks yang berbeda, mismatch tetap bisa muncul. Karena itu guard harus dipadukan dengan placeholder stabil atau data awal yang dikirim server secara eksplisit.

6. Audit payload SSR secara rutin

Jangan menganggap payload hydration aman hanya karena halaman terlihat benar. Audit secara aktif:

  • Lihat View Source dan cari nama file, path, token, hostname internal, atau env key.
  • Periksa ukuran payload JSON yang disisipkan ke HTML.
  • Cari field yang tidak dipakai komponen tetapi tetap terkirim.
  • Tambahkan pengujian snapshot atau assertion untuk field terlarang.

Untuk tim yang memakai generator kode, AI tooling, atau pipeline otomatis, audit ini penting karena kebocoran sering terjadi dari lapisan “bantu” di luar kode komponen utama.

Contoh implementasi pada framework yang umum

Next.js: jangan kirim hasil server mentah

Di Next.js, apa pun yang Anda kembalikan dari fungsi data server-side harus dianggap akan terlihat oleh browser. Karena itu, bentuk payload publik secara eksplisit.

// Contoh pola umum, berlaku untuk SSR data fetching
export async function getServerSideProps() {
  const files = await listFilesFromStorage()

  return {
    props: {
      files: files.map((f) => ({
        id: f.id,
        name: f.displayName,
        sizeLabel: formatSize(f.size),
      })),
    },
  }
}

Hindari ini:

return {
  props: {
    files,
    env: process.env,
  },
}

Jika ada data yang hanya diperlukan untuk keputusan server, gunakan di server saja dan kirim hasil akhirnya, bukan input mentahnya.

Nuxt: bedakan data publik dan state runtime

Di Nuxt, prinsip yang sama berlaku pada data yang masuk ke payload SSR. Jangan menyimpan metadata internal pada state yang akan dihidrasi. Gunakan transformer atau composable yang mengembalikan bentuk data publik yang kecil dan stabil.

Untuk nilai yang hanya ada di browser, render fallback tetap di server lalu isi setelah komponen mounted. Jika harus berbeda berdasarkan user preference, lebih baik bawa preferensi itu lewat sumber yang bisa diakses server secara aman, misalnya cookie yang telah divalidasi.

Inertia: props adalah kontrak publik

Pada Inertia, data yang dibagikan sebagai props ke halaman harus diperlakukan sebagai data publik. Hindari kebiasaan mengoper model lengkap atau hasil serialisasi otomatis tanpa filter.

// Pola umum di controller/server adapter
return Inertia::render('Files/Index', [
  'files' => array_map(fn ($f) => [
    'id' => $f['id'],
    'name' => $f['display_name'],
    'sizeLabel' => formatSize($f['size']),
  ], $files),
])

Jika Anda memakai shared props global, audit ekstra hati-hati. Kebocoran sering muncul dari data global yang “praktis” tetapi terlalu luas, misalnya seluruh user object, permission matrix mentah, atau context debug.

Kesalahan umum yang sering luput

Mengandalkan hasil sanitasi di komponen

Jika data sensitif sudah telanjur masuk payload SSR, menyembunyikannya di komponen tidak membantu. Pengguna tetap bisa melihatnya dari HTML atau script hydration.

Mengirim objek untuk berjaga-jaga

“Biar nanti kalau dipakai tinggal ada” adalah alasan umum payload membengkak dan bocor. Untuk SSR, kirim sesedikit mungkin. Tambah field saat benar-benar diperlukan.

Mencampur state auth server dengan state cache klien

Misalnya server menganggap user sudah login berdasarkan cookie, tetapi klien membaca status lama dari storage. Jika kedua sumber dipakai langsung untuk render awal, tampilan tombol, avatar, atau menu bisa berubah saat hydration.

Membuat key list dari nilai acak

Key yang berubah antara server dan klien dapat memicu rerender tidak perlu dan perilaku aneh pada daftar. Gunakan ID stabil dari data, bukan timestamp atau random.

Cara debugging saat mismatch terasa misterius

  1. Bandingkan output server dan klien. Cari elemen pertama yang berubah setelah mount.
  2. Log view model publik, bukan objek internal. Pastikan field yang dikirim memang sesuai kontrak.
  3. Matikan bagian dinamis sementara seperti waktu, locale browser, atau pembacaan storage untuk mengisolasi sumber mismatch.
  4. Periksa payload HTML langsung, bukan hanya state di DevTools setelah aplikasi hidup.
  5. Cari perbedaan environment antara local, CI, staging, dan production: timezone, locale, cookie, header proxy, urutan file, dan data cache.

Jika mismatch hanya terjadi sesekali, curigai sumber nondeterministik: urutan object iteration yang tidak dijamin untuk logika tertentu, timestamp render, race condition fetch, atau data eksternal yang berubah di sela render dan hydration.

Checklist SSR aman sebelum rilis

  • Apakah semua data yang dikirim ke klien dibentuk lewat whitelist?
  • Apakah ada path file, hostname internal, env key, token, atau metadata storage dalam HTML awal?
  • Apakah render awal bergantung pada localStorage, window, waktu sekarang, atau angka acak?
  • Apakah daftar dan key item stabil antara server dan klien?
  • Apakah shared props atau global state sudah diaudit ukurannya dan isinya?
  • Apakah ada test atau pemeriksaan otomatis untuk field sensitif pada payload SSR?

Penutup

SSR aman berarti dua hal sekaligus: HTML awal harus konsisten untuk hydration, dan payload yang menyertainya tidak boleh membawa data privat. Bug hydration sering dianggap masalah frontend biasa, padahal akar masalahnya bisa berupa desain kontrak data yang terlalu longgar.

Solusi yang paling efektif biasanya sederhana: serialisasi aman di server, pisahkan state privat dari state publik, whitelist props, gunakan placeholder stabil untuk data berbasis browser, dan audit payload SSR secara rutin. Dengan pendekatan ini, Anda bukan hanya menghilangkan warning hydration, tetapi juga menutup jalur kebocoran yang sering tidak terlihat sampai terlambat.