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: falseplushasPartsupaya 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 Google | Yang harus benar |
|---|---|
isAccessibleForFree: false | Ada di JSON-LD tiap artikel premium |
cssSelector | Menunjuk elemen yang benar-benar ada di HTML |
| Perlakuan yang sama | Googlebot mendapat HTML yang sama persis dengan pembaca anonim |
| Cuplikan gratis nyata | Cuplikannya 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
| Uji | Harus |
|---|---|
curl tanpa cookie ke halaman artikel premium | Isi lengkap tidak ada di HTML |
| Muat halaman dengan JavaScript mati | Cuplikan + ajakan berlangganan terlihat |
| Muat halaman dengan CSS mati | Isi lengkap tetap tidak muncul |
Panggil /api/artikel/<slug>.json tanpa sesi | Tidak mengembalikan isi premium |
| Uji Rich Results Google | JSON-LD valid, isAccessibleForFree terbaca |
| Periksa respons island di DevTools | Cache-Control: private, no-store |
| Periksa respons halaman | Cache-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.