SEO untuk Web SPA di Cloudflare Pages: Judul, Meta, dan Isi Langsung Terbaca Google

Web single-page app (SPA) yang memuat semua isi lewat JavaScript sering susah naik di Google, dan pratinjau tautannya di WhatsApp atau Telegram kosong. Solusinya tidak harus pindah framework. Dengan Cloudflare Pages Functions, server bisa menyisipkan judul, deskripsi, gambar, dan isi artikel ke HTML sebelum dikirim, sementara tampilan SPA Anda tetap berjalan seperti biasa. Cara ini dipakai langsung di situs OutsiderDev.
Kenapa SPA sulit terindeks?
- Google merender belakangan. Google memproses halaman dalam tiga tahap: crawl, render, lalu indeks. Halaman berbasis JavaScript bisa menunggu di antrean render sebelum isinya terbaca, sehingga indeks lebih lambat.
- Tidak semua bot menjalankan JavaScript. Google sendiri tetap menyarankan server-side rendering atau pre-rendering. Pratinjau tautan di aplikasi chat dan media sosial umumnya hanya membaca HTML awal, jadi tag
og:yang dipasang lewat JavaScript tidak terlihat. - URL pakai tanda pagar. Alamat seperti
/#artikelatau/#page_3tidak dianggap halaman terpisah. Google menyarankan URL asli (History API), misalnya/artikel/judul-artikel. - Semua halaman berjudul sama. Tanpa judul dan deskripsi unik per halaman, hasil pencarian terlihat seragam dan tidak menarik diklik.
Gambaran solusinya
Setiap permintaan halaman melewati satu Pages Function. Function itu membaca slug dari URL, mengambil data dari database (di sini Supabase), lalu menyisipkan meta tag dan isi konten ke index.html. Browser menerima HTML yang sudah lengkap, lalu JavaScript SPA mengambil alih seperti biasa. File statis seperti CSS, JS, dan gambar tidak melewati Function sama sekali.
Langkah 1: Buat slug untuk setiap konten
Tambahkan kolom slug unik di tabel konten. Slug dibuat dari judul (huruf kecil, spasi jadi tanda minus) dan sebaiknya tidak berubah walau judul diedit, supaya URL lama tidak mati.
alter table news add column slug text unique; alter table news add column updated_at timestamptz default now();
Langkah 2: Susun folder proyek
public/ -> index.html, assets/, img/, _routes.json functions/ [[path]].js -> menangkap semua URL _lib/handler.js -> logika SEO wrangler.toml
Nama file berkurung ganda [[path]].js menangkap URL sedalam apa pun, misalnya /artikel/judul. Folder yang diawali garis bawah seperti _lib tidak menjadi rute, jadi aman untuk kode bantu.
Langkah 3: Kecualikan file statis
File public/_routes.json menentukan URL mana yang menjalankan Function. Permintaan aset statis gratis dan tanpa batas, sedangkan pemanggilan Function dihitung ke kuota harian Workers Free (100.000 permintaan per hari). Jadi kecualikan folder aset:
{
"version": 1,
"include": ["/*"],
"exclude": ["/assets/*", "/img/*"]
}
Langkah 4: Tulis Function penyisip SEO
Versi ringkasnya seperti di bawah. env.ASSETS.fetch mengambil index.html dari folder public, lalu tag di dalam <head> dan isi awal halaman diganti sesuai data:
// functions/[[path]].js
const SB = 'https://ID-PROJECT.supabase.co';
export async function onRequest({ request, env }) {
const url = new URL(request.url);
const shell = await (await env.ASSETS.fetch(new URL('/', url).href)).text();
const m = url.pathname.match(/^\/artikel\/([a-z0-9-]+)$/);
if (!m) return html(shell, 200);
const res = await fetch(`${SB}/rest/v1/news?select=*&status=eq.Active&slug=eq.${m[1]}`,
{ headers: { apikey: env.SUPABASE_KEY } });
const [a] = await res.json();
if (!a) return html(shell.replace('<head>', '<head><meta name="robots" content="noindex">'), 404);
const desc = esc(a.content.replace(/<[^>]+>/g, '').slice(0, 155));
const head = `<title>${esc(a.title)}</title>
<meta name="description" content="${desc}">
<link rel="canonical" href="${url.origin}${url.pathname}">
<meta property="og:title" content="${esc(a.title)}">
<meta property="og:description" content="${desc}">
<meta property="og:image" content="${url.origin}${a.thumbnail}">
<meta property="og:type" content="article">`;
const body = `<article id="ssr-root"><h1>${esc(a.title)}</h1>${a.content}</article>`;
return html(shell.replace(/<title>.*?<\/title>/, head).replace('<!--SSR-->', body), 200);
}
const esc = s => String(s).replace(/[&<>"]/g, c => `&#${c.charCodeAt(0)};`);
const html = (b, status) => new Response(b, { status,
headers: { 'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, max-age=0, s-maxage=300' } });
Taruh penanda <!--SSR--> di index.html pada tempat konten biasa dirender. Simpan kunci Supabase sebagai variabel di Settings > Variables proyek Pages.
Langkah 5: Hal yang sering terlewat
- Bersihkan HTML konten. Isi dari editor admin ditulis mentah ke halaman, jadi buang
<script>,iframe, atributon*, dan tautanjavascript:sebelum disisipkan. - Kode status yang benar. Slug yang tidak ada harus membalas 404 plus
noindex, bukan 200. Jika database sedang tidak bisa dijangkau, balas 503 dengan headerRetry-Aftersupaya Google mencoba lagi alih-alih menghapus halaman dari indeks. - Satu konten, satu URL. Alihkan (301)
/index.html, garis miring di akhir, dan domain lama ke alamat utama, lalu pasangcanonical. - Sitemap dan robots dari database. Layani
/sitemap.xmldan/robots.txtdari Function yang sama, sehingga artikel baru otomatis masuk sitemap dan artikel Draft tidak ikut. - Cache di tepi jaringan. Header
s-maxage=300membuat halaman di-cache 5 menit, sehingga database tidak dipanggil di setiap kunjungan bot. - Tambahkan JSON-LD. Data terstruktur jenis
Article(judul, tanggal, penulis, gambar) membantu Google memahami halaman.
Langkah 6: Deploy dan cek hasilnya
Deploy lewat Git atau npx wrangler pages deploy public dari folder induk, bukan drag-and-drop, karena folder functions harus ikut dikompilasi. Setelah itu cek apakah judul dan isi sudah ada di HTML awal:
curl -s https://domainanda.com/artikel/slug-artikel | head -40
Jika <title> dan paragraf artikel sudah terlihat di hasil curl, berarti Google dan pratinjau tautan juga melihatnya. Langkah berikutnya adalah mendaftarkan situs dan sitemap ke Google Search Console (lihat artikel cara daftar Google Search Console).
Sumber: JavaScript SEO basics, Google Search Central, Functions routing, Cloudflare Pages, dan Functions pricing, Cloudflare Pages.