← Semua pembelajaran / Astro Nol → Portal Berita
Fase 1 · Routing, Layout & Model Rendering

Server islands — halaman ter-cache dengan isi per pembaca

Ini materi terpenting di seluruh roadmap untuk kasusmu. Server island memisahkan bagian yang sama untuk semua orang dari bagian yang berbeda per pembaca, sehingga yang pertama bisa di-cache di edge dan yang kedua tetap benar.

Sumber asli docs.astro.build Resmi Rangkuman ~11 menit baca

Intisari

  • server:defer mengeluarkan satu komponen dari render halaman; ia diambil terpisah lewat /_server-islands/<Nama>.
  • Halamannya tetap utuh cacheable karena tidak mengandung apa pun yang personal.
  • Props dienkripsi ke dalam query string. Kuncinya dibuat acak tiap build — dan itu masalah nyata saat rolling deploy di ECS.
  • Kalau props melebihi 2048 byte, Astro beralih ke POST, dan POST tidak di-cache browser.
  • Astro.url di dalam island bernilai /_server-islands/<Nama>, bukan URL halaman. URL asli ada di header Referer.

Masalah yang diselesaikannya

Halaman artikel portalmu 98% sama untuk semua orang: judul, byline, gambar, badan artikel, artikel terkait. Sisanya 2% berbeda: apakah pembaca ini melihat artikel penuh atau potongan plus ajakan berlangganan.

Tanpa server island, 2% itu meracuni 100%-nya. Begitu halaman membaca cookie sesi, ia jadi personal, dan Cache-Control harus private — sehingga seluruh halaman harus dirender ulang untuk tiap pembaca. Itu persis situasi CI3-mu sekarang, dan sebabnya 500 ribu request per jam sampai ke origin.

TANPA server island:
  pembaca → Cloudflare (MISS, selalu) → ALB → Astro → MySQL
  1.000.000 pembaca = 1.000.000 render

DENGAN server island:
  pembaca → Cloudflare (HIT) → HTML dari edge
                └→ satu fetch kecil → ALB → Astro → cek langganan
  1.000.000 pembaca = 1 render halaman + 1.000.000 cek langganan yang murah

Bentuknya

---
// src/pages/berita/[slug].astro
export const prerender = false;

import Layout from "@/layouts/Artikel.astro";
import GerbangPaywall from "@/components/GerbangPaywall.astro";
import { ambilArtikelPublik } from "@/lib/artikel";

const artikel = await ambilArtikelPublik(Astro.params.slug!);
if (!artikel) return Astro.rewrite("/404");

// Halaman ini tidak menyentuh cookie sama sekali — itu syaratnya.
Astro.response.headers.set(
  "Cache-Control",
  "public, max-age=0, s-maxage=300, stale-while-revalidate=86400",
);
Astro.response.headers.set("Cache-Tag", `artikel-${artikel.id}`);

// Untuk artikel premium, hanya paragraf pembuka yang ikut ke HTML.
const isiTerbuka = artikel.premium ? artikel.cuplikan : artikel.isi;
---
<Layout artikel={artikel}>
  <div class="badan-artikel" set:html={isiTerbuka} />

  {artikel.premium && (
    <GerbangPaywall server:defer artikelId={artikel.id}>
      <div slot="fallback" class="paywall-rangka" aria-hidden="true">
        <div class="baris"></div><div class="baris"></div>
      </div>
    </GerbangPaywall>
  )}
</Layout>
---
// src/components/GerbangPaywall.astro — dirender per pembaca
import { memberDariCookie } from "@/lib/auth";
import { ambilIsiPenuh } from "@/lib/artikel";

interface Props { artikelId: number; }
const { artikelId } = Astro.props;

const member = await memberDariCookie(Astro.cookies);
const berhak = member?.langgananAktif === true;

const isi = berhak ? await ambilIsiPenuh(artikelId) : null;

// Respons island ini TIDAK BOLEH di-cache bersama.
Astro.response.headers.set("Cache-Control", "private, no-store");
---
{berhak ? (
  <div class="badan-artikel" set:html={isi} />
) : (
  <aside class="paywall">
    <h2>Lanjutkan membaca</h2>
    <p>Artikel ini untuk pelanggan.</p>
    <a class="tombol" href={`/langganan?dari=${artikelId}`}>Berlangganan</a>
  </aside>
)}

Perhatikan apa yang TIDAK dilakukan halaman itu. Untuk artikel premium, isi lengkapnya tidak pernah masuk ke HTML yang di-cache. Ini bukan detail — ini bedanya paywall sungguhan dengan paywall yang bisa dilewati dengan Ctrl+U. Mengirim seluruh artikel lalu menutupinya dengan CSS adalah pola yang masih dipakai banyak portal Indonesia, dan itu bukan paywall.

Empat jebakan yang harus kamu tahu sekarang

1. ASTRO_KEY dan rolling deploy

Props island dienkripsi memakai kunci yang dibuat acak setiap build dan ditanam di bundel server. Saat rolling deploy di ECS, task lama dan task baru hidup bersamaan di belakang ALB yang sama — dengan dua kunci berbeda.

Pembaca memuat halaman  → dilayani task LAMA (kunci A)
Browser mengambil island → dirouting ke task BARU (kunci B)
                         → gagal mendekripsi props → island error

Perbaikannya: buat kunci tetap sekali, simpan di Secrets Manager, dan berikan ke semua task lewat variabel lingkungan.

pnpm astro create-key
# Simpan hasilnya sebagai ASTRO_KEY di AWS Secrets Manager,
# lalu injeksikan ke task definition (Fase 10).

Ini akan terlihat seperti gangguan acak lima menit tiap kali deploy. Islands gagal, lalu pulih sendiri setelah task lama habis — jadi saat kamu memeriksanya semuanya sudah normal. Pasang ASTRO_KEY sebelum deploy pertama, bukan setelah orang melaporkannya.

2. Batas 2048 byte dan diamnya POST

Props dikirim sebagai query string terenkripsi. Kalau hasilnya melewati 2048 byte, Astro otomatis beralih ke POST — dan respons POST tidak di-cache browser sama sekali.

<!-- BURUK: seluruh objek artikel ikut dienkripsi ke URL -->
<GerbangPaywall server:defer artikel={artikel} />

<!-- BAIK: kirim ID saja, ambil ulang di dalam island -->
<GerbangPaywall server:defer artikelId={artikel.id} />

Aturannya: kirim pengenal, bukan data. Ini juga lebih aman — props terenkripsi tetap bolak-balik lewat jaringan, dan tidak ada gunanya mengirimkan isi artikel premium ke browser yang belum tentu berhak menerimanya.

3. Astro.url berbohong di dalam island

---
// Di dalam komponen server:defer:
Astro.url.pathname;   // "/_server-islands/GerbangPaywall"  ← BUKAN URL halaman

// URL halaman yang sebenarnya:
const asal = Astro.request.headers.get("referer");
---

Apa pun yang menghitung sesuatu dari URL — analitik, URL kanonik, penentuan kategori — akan salah kalau dijalankan di dalam island. Kalau butuh URL halaman, kirimkan sebagai prop dari halaman induknya, jangan menebak dari Referer yang bisa hilang.

4. Fallback wajib punya tinggi

Isi island datang setelah HTML utama. Kalau fallback-nya kosong atau setinggi nol, halaman akan "melompat" saat island masuk — dan Cumulative Layout Shift adalah salah satu Core Web Vitals yang dinilai Google untuk portal berita.

<GerbangPaywall server:defer artikelId={artikel.id}>
  <!-- Tinggi kira-kira sama dengan isi sungguhannya -->
  <div slot="fallback" class="paywall-rangka" style="min-height:320px" aria-hidden="true"></div>
</GerbangPaywall>

Yang boleh dikirim sebagai props

BolehTidak boleh
objek biasa, number, string, array, Map, Set, RegExp, Date, BigInt, URL, Uint8Array dan kerabatnya fungsi (tidak bisa diserialisasi), objek dengan referensi melingkar, instance kelas dengan method

Kapan island lebih dari satu

Boleh, dan sering perlu. Di halaman artikel portalmu biasanya ada tiga:

IslandIsinyaCache
MenuAkunNama pembaca atau tombol Masuk di headerprivate, no-store
GerbangPaywallIsi penuh atau ajakan berlanggananprivate, no-store
BookmarkStatus tersimpan/belum untuk artikel iniprivate, no-store

Tapi tiga island = tiga request tambahan ke origin per halaman. Di 1,25 juta request per jam itu bukan angka kecil. Gabungkan yang membutuhkan data yang sama: satu island KonteksPembaca yang sekali membaca sesi lalu merender header, paywall, dan tombol bookmark sekaligus lebih murah daripada tiga island yang masing-masing membaca sesi. Pola penggabungannya di Fase 5.

Batas yang jujur

KelemahanAkibatnya
Isi island tidak ada di HTML awalTidak terbaca crawler. Jangan taruh apa pun yang harus terindeks di dalamnya.
Butuh JavaScript untuk memasukkan hasilnyaPembaca dengan JS mati hanya melihat fallback. Pastikan fallback-nya masuk akal, bukan spinner selamanya.
Selalu satu round-trip tambahanJangan pakai untuk hal yang tidak benar-benar personal.
Butuh adapterTidak bisa dipakai di situs statis murni.

Latihan: buat halaman yang mengeset Cache-Control: public, s-maxage=300 dan berisi satu komponen server:defer yang mencetak new Date().toISOString(). Jalankan pnpm build && pnpm preview, buka DevTools → Network, dan muat ulang beberapa kali. Verifikasi tiga hal: ada request terpisah ke /_server-islands/…, waktu di island berubah tiap muat sementara sisanya tidak, dan props-nya memang terenkripsi di query string — bukan terbaca sebagai teks biasa.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.