โ† Semua pembelajaran / Astro Nol โ†’ Portal Berita
Fase 5 ยท Membership, Auth & Paywall

Membangun paywall yang tidak bisa dilewati

Paywall yang mengirim seluruh artikel lalu menutupinya dengan CSS bukan paywall. Ini cara membangunnya supaya isinya benar-benar tidak pernah meninggalkan server.

Intisari

  • Isi premium tidak boleh ikut ke HTML untuk pembaca yang tidak berhak โ€” titik.
  • Kerangka artikel tetap di-cache di edge; hanya blok berbayar yang jadi server island.
  • Google butuh isAccessibleForFree: false plus hasPart supaya mengindeks tanpa menganggapmu cloaking.
  • Paywall terukur ("3 artikel gratis per bulan") harus dihitung di server, bukan di localStorage.
  • Satu island untuk seluruh konteks pembaca lebih murah daripada tiga island terpisah.

Yang salah dan sangat umum

<!-- INI BUKAN PAYWALL -->
<div class:list={["badan", { terkunci: !berhak }]} set:html={artikel.isi} />

<style>
  .terkunci { max-height: 400px; overflow: hidden; }
</style>

Seluruh artikel ada di HTML. Ctrl+U membukanya. Mode baca peramban membukanya. Mematikan CSS membukanya. Ekstensi peramban yang menghapus kelas itu tersedia gratis di toko ekstensi. Ini pola yang masih dipakai banyak portal berita Indonesia, dan setiap pembaca yang sedikit penasaran sudah tahu cara melewatinya.

Struktur yang benar

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

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

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

// Halaman ini TIDAK menyentuh cookie. Itu syarat mutlaknya.
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}`);
---
<Layout artikel={artikel}>
  <!-- Cuplikan gratis: ikut di HTML, terindeks Google -->
  <div class="badan-artikel" set:html={artikel.cuplikan} />

  {artikel.premium ? (
    <KonteksPembaca server:defer artikelId={artikel.id}>
      <div slot="fallback" class="paywall-rangka" style="min-height:360px" aria-hidden="true"></div>
    </KonteksPembaca>
  ) : (
    <div class="badan-artikel" set:html={artikel.isiLanjutan} />
  )}
</Layout>
---
// src/components/KonteksPembaca.astro โ€” dirender per pembaca
import { memberDariCookie } from "@/lib/auth";
import { cekHak } from "@/lib/hak";
import { ambilIsiLanjutan } from "@/lib/artikel";
import BlokLangganan from "./BlokLangganan.astro";

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

const member = await memberDariCookie(Astro.cookies);
const hak = await cekHak(member, artikelId, Astro.cookies, Astro.locals.ipPembaca);

// Isi lengkap HANYA diambil kalau memang berhak.
const isi = hak.boleh ? await ambilIsiLanjutan(artikelId) : null;

Astro.response.headers.set("Cache-Control", "private, no-store");
Astro.response.headers.set("Vary", "Cookie");
---
{hak.boleh ? (
  <>
    <div class="badan-artikel" set:html={isi} />
    {hak.alasan === "terukur" && (
      <p class="sisa-gratis">Sisa {hak.sisa} artikel gratis bulan ini.</p>
    )}
  </>
) : (
  <BlokLangganan artikelId={artikelId} sudahLogin={member !== null} />
)}

Baris const isi = hak.boleh ? โ€ฆ : null adalah inti keamanannya. Kalau pembaca tidak berhak, kueri untuk mengambil isi lengkap tidak pernah dijalankan. Isinya tidak pernah ada di memori, apalagi di HTML. Itu bedanya dengan menyembunyikan: tidak ada yang bisa disembunyikan kalau tidak pernah diambil.

Aturan hak akses di satu tempat

// src/lib/hak.ts
export type Hak =
  | { boleh: true; alasan: "langganan" | "gratis" | "terukur"; sisa?: number }
  | { boleh: false; alasan: "kuota-habis" | "butuh-langganan" };

const KUOTA_BULANAN = 3;

export async function cekHak(
  member: Member | null,
  artikelId: number,
  cookies: AstroCookies,
  ip: string,
): Promise<Hak> {
  if (member?.langgananAktif) {
    return { boleh: true, alasan: "langganan" };
  }

  // Paywall terukur โ€” dihitung di SERVER
  const kunci = member ? `m:${member.id}` : `a:${hashIp(ip)}`;
  const terpakai = await hitungKuota(kunci, artikelId);

  if (terpakai <= KUOTA_BULANAN) {
    return { boleh: true, alasan: "terukur", sisa: KUOTA_BULANAN - terpakai };
  }

  return { boleh: false, alasan: member ? "kuota-habis" : "butuh-langganan" };
}

Kuota di localStorage adalah kuota yang tidak ada. Mode penyamaran mengosongkannya, DevTools menghapusnya dalam dua klik, dan setiap peramban punya hitungan sendiri. Kalau paywall terukurmu adalah bagian dari model bisnis, hitung di server: tabel kuota_baca berisi (kunci, artikel_id, bulan) dengan UNIQUE, sehingga membaca ulang artikel yang sama tidak menghabiskan jatah kedua.

async function hitungKuota(kunci: string, artikelId: number): Promise<number> {
  const bulan = new Date().toISOString().slice(0, 7);   // "2026-08"

  await dbTulis
    .insertInto("kuota_baca")
    .values({ kunci, artikel_id: artikelId, bulan, dibaca_pada: new Date() })
    .onDuplicateKeyUpdate({ dibaca_pada: new Date() })   // idempoten
    .execute();

  const { n } = await dbTulis
    .selectFrom("kuota_baca")
    .select((eb) => eb.fn.countAll<number>().as("n"))
    .where("kunci", "=", kunci)
    .where("bulan", "=", bulan)
    .executeTakeFirstOrThrow();

  return n;
}

Supaya Google tetap mengindeks artikel berbayar

Menyajikan isi lengkap ke Googlebot tapi tidak ke pembaca adalah cloaking โ€” pelanggaran yang bisa menghapus portalmu dari indeks. Google menyediakan jalur resminya: beri tahu bahwa artikel ini berbayar, lewat data terstruktur.

const jsonLd = {
  "@context": "https://schema.org",
  "@type": "NewsArticle",
  headline: artikel.judul,
  datePublished: artikel.terbitPada.toISOString(),

  isAccessibleForFree: !artikel.premium,

  ...(artikel.premium && {
    hasPart: {
      "@type": "WebPageElement",
      isAccessibleForFree: false,
      cssSelector: ".badan-berbayar",
    },
  }),
};
<!-- Selector-nya harus BENAR-BENAR ada di HTML yang dilihat Googlebot -->
<div class="badan-berbayar">
  <KonteksPembaca server:defer artikelId={artikel.id} />
</div>
Syarat GoogleYang harus benar
isAccessibleForFree: falseAda di JSON-LD tiap artikel premium
cssSelectorMenunjuk elemen yang benar-benar ada di HTML
Perlakuan yang samaGooglebot mendapat HTML yang sama persis dengan pembaca anonim
Cuplikan gratis nyataCuplikannya harus benar-benar bisa dibaca semua orang

Gabungkan island, jangan pisah

<!-- BURUK: tiga island, tiga request, tiga pembacaan sesi -->
<MenuAkun server:defer />
<GerbangPaywall server:defer artikelId={id} />
<TombolBookmark server:defer artikelId={id} />

Di 1,25 juta request per jam, tiga island berarti tiga kali beban origin yang seharusnya satu. Gabungkan menjadi satu island yang membaca sesi sekali lalu merender ketiganya, atau kirim hasilnya ke island lain lewat atribut data yang dibaca skrip kecil.

Daftar periksa sebelum peluncuran

UjiHarus
curl tanpa cookie ke halaman artikel premiumIsi lengkap tidak ada di HTML
Muat halaman dengan JavaScript matiCuplikan + ajakan berlangganan terlihat
Muat halaman dengan CSS matiIsi lengkap tetap tidak muncul
Panggil /api/artikel/<slug>.json tanpa sesiTidak mengembalikan isi premium
Uji Rich Results GoogleJSON-LD valid, isAccessibleForFree terbaca
Periksa respons island di DevToolsCache-Control: private, no-store
Periksa respons halamanCache-Control publik, tanpa Set-Cookie

Baris keempat itu kebocoran yang paling sering terjadi. Tim memasang paywall di halaman HTML dengan teliti, lalu lupa bahwa endpoint JSON yang dipakai aplikasi mobile menyajikan artikel yang sama tanpa pemeriksaan apa pun. Uji setiap jalur keluar, bukan hanya yang terlihat di browser.

Latihan: bangun paywall lengkap dengan KonteksPembaca.astro, dan uji dengan curl -s https://localhost/berita/artikel-premium | grep -c "kalimat-dari-paragraf-kesepuluh". Hasilnya harus 0 tanpa cookie sesi, dan 1 dengan cookie member yang berlangganan. Lalu buat paywall terukur berbasis tabel dan buktikan bahwa membuka artikel yang sama dua kali hanya memakan satu jatah.

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