← Semua pembelajaran / Astro Nol → Portal Berita
Fase 9 · Migrasi dari CodeIgniter 3

SEO portal berita saat migrasi

Portal berita punya aturan SEO sendiri yang tidak berlaku di situs lain — dan migrasi adalah momen paling rawan untuk melanggarnya.

Intisari

  • Sitemap Google News hanya memuat artikel 2 hari terakhir — maksimum 1.000 URL.
  • URL kanonik absolut di tiap halaman. Ini yang mencegah parameter pelacakan jadi duplikat.
  • NewsArticle JSON-LD dengan isAccessibleForFree untuk artikel berbayar (Fase 5).
  • datePublished dan dateModified harus akurat dan ber-zona waktu.
  • Jangan mengubah bentuk URL dan platform sekaligus kalau bisa dihindari — kalau harus, lakukan bersamaan sekali, bukan dua kali.

Kanonik yang benar

---
// src/layouts/Dasar.astro
const kanonik = new URL(Astro.url.pathname, Astro.site).href;
---
<link rel="canonical" href={kanonik} />
Yang dicegah kanonikContoh
Parameter pelacakan?utm_source=facebook
Variasi trailing slash/berita/halo/ vs /berita/halo
Beda protokol atau hostwww. vs tanpa www.
Halaman yang bisa dicapai lewat dua jalurArtikel di dua kategori

Perhatikan bahwa kanonik dihitung dari pathname, bukan dari Astro.url utuh. Kalau kamu memakai URL lengkap, parameter pelacakan ikut masuk ke kanonik — dan tiap tautan yang dibagikan di media sosial jadi URL kanonik tersendiri, yang justru kebalikan dari tujuannya.

Ingat juga peringatan dari Fase 1: di dalam server island, Astro.url bernilai /_server-islands/…. Layout yang menghitung kanonik tidak boleh dipakai di dalam island.

Sitemap berita

// src/pages/sitemap-berita.xml.ts
export const prerender = false;

export const GET: APIRoute = async ({ site }) => {
  // Google News: hanya 2 hari terakhir, maksimum 1.000 URL.
  const artikel = await dbBaca
    .selectFrom("artikel")
    .select(["slug", "judul", "terbit_pada"])
    .where("status", "=", "terbit")
    .where("terbit_pada", ">=", new Date(Date.now() - 2 * 86400000))
    .where("terbit_pada", "<=", new Date())
    .orderBy("terbit_pada", "desc")
    .limit(1000)
    .execute();

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
${artikel.map((a) => `  <url>
    <loc>${new URL(`/berita/${a.slug}`, site).href}</loc>
    <news:news>
      <news:publication>
        <news:name>Portal Contoh</news:name>
        <news:language>id</news:language>
      </news:publication>
      <news:publication_date>${a.terbit_pada!.toISOString()}</news:publication_date>
      <news:title>${escXml(a.judul)}</news:title>
    </news:news>
  </url>`).join("\n")}
</urlset>`;

  return new Response(xml, {
    headers: {
      "Content-Type": "application/xml; charset=utf-8",
      "Cache-Control": "public, max-age=0, s-maxage=300",
    },
  });
};

Batas dua hari dan seribu URL bukan saran. Sitemap berita yang memuat artikel lebih lama akan diabaikan atau memicu peringatan di Search Console. Arsip lengkap masuk ke sitemap biasa yang terpisah, dipecah maksimal 50.000 URL per berkas dengan indeks sitemap di atasnya.

Sitemap arsip

// src/pages/sitemap-[hal].xml.ts — 50.000 URL per berkas
const PER_HAL = 50_000;

export const GET: APIRoute = async ({ params, site }) => {
  const hal = Number(params.hal);
  if (!Number.isInteger(hal) || hal < 1) return new Response(null, { status: 404 });

  const artikel = await dbBaca
    .selectFrom("artikel")
    .select(["slug", "diubah_pada", "terbit_pada"])
    .where("status", "=", "terbit")
    .orderBy("id", "asc")
    .limit(PER_HAL)
    .offset((hal - 1) * PER_HAL)
    .execute();

  if (artikel.length === 0) return new Response(null, { status: 404 });
  // … susun XML
};

Data terstruktur

const jsonLd = {
  "@context": "https://schema.org",
  "@type": "NewsArticle",
  mainEntityOfPage: { "@type": "WebPage", "@id": kanonik },
  headline: artikel.judul.slice(0, 110),        // Google memotong di sekitar 110
  description: artikel.ringkasan ?? undefined,
  image: artikel.gambarSampul ? [artikel.gambarSampul] : undefined,
  datePublished: artikel.terbitPada.toISOString(),
  dateModified: (artikel.diubahPada ?? artikel.terbitPada).toISOString(),
  author: [{ "@type": "Person", name: artikel.penulis, url: `${situs}/penulis/${artikel.penulisSlug}` }],
  publisher: {
    "@type": "Organization",
    name: "Portal Contoh",
    logo: { "@type": "ImageObject", url: `${situs}/logo-600x60.png`, width: 600, height: 60 },
  },
  isAccessibleForFree: !artikel.premium,
  ...(artikel.premium && {
    hasPart: {
      "@type": "WebPageElement",
      isAccessibleForFree: false,
      cssSelector: ".badan-berbayar",
    },
  }),
};
Kesalahan umumAkibatnya
dateModified berubah tiap renderGoogle mengira artikel terus disunting; kepercayaannya turun
Tanggal tanpa zona waktuDitafsirkan salah; artikel terlihat terbit di masa depan
headline lebih dari 110 karakterDianggap tidak valid
cssSelector menunjuk elemen yang tidak adaPaywall tidak dikenali; risiko dianggap cloaking
Logo publisher bukan rasio yang didukungPeringatan di Search Console

Baris pertama itu jebakan yang halus. Kalau kamu menuliskan dateModified dari new Date() — atau dari kolom yang ikut berubah saat penghitung hits di-update — maka setiap perayapan melihat tanggal yang berbeda. Ambil dari kolom yang hanya berubah saat editor benar-benar menyunting isinya.

Tiga hal khusus Google News

HalAturannya
KesegaranArtikel harus muncul di sitemap berita dalam hitungan menit setelah terbit
URL stabilSlug tidak boleh berubah setelah terbit — kalau harus, 301 dan pertahankan selamanya
Isi lengkap untuk crawlerIsi berbayar tetap boleh diindeks asal ditandai dengan benar (Fase 5)

Yang tidak boleh diindeks

---
// Halaman hasil pencarian, paginasi dalam, filter
const noindex = Astro.url.searchParams.has("q") || hal > 20;
---
{noindex && <meta name="robots" content="noindex, follow" />}

follow-nya penting: kamu tidak ingin halaman itu terindeks, tapi kamu ingin Google mengikuti tautan di dalamnya untuk menemukan artikelmu. noindex, nofollow memutus jalur penemuan itu.

Pantau selama migrasi

MetrikDi manaEkspektasi normal
Halaman terindeksSearch Console → PagesTurun sedikit lalu pulih dalam 2–4 minggu
Error perayapanSearch ConsoleTidak boleh naik
Klik & tayanganSearch Console → PerformanceTurun 5–15% sementara, lalu pulih
Core Web VitalsSearch ConsoleHarus membaik — kalau tidak, ada yang salah
Item Google NewsPublisher CenterTidak boleh turun

Penurunan sementara adalah normal; penurunan yang tidak pulih setelah enam minggu adalah masalah. Kalau itu terjadi, tersangka pertama selalu sama: redirect yang terlewat, kanonik yang salah, atau bentuk URL yang berubah tanpa peta lengkap. Semuanya bisa diperbaiki — tapi lebih cepat ditemukan lebih baik.

Latihan: bangun sitemap berita dan sitemap arsip berindeks. Validasi keduanya dengan Search Console. Lalu jalankan Rich Results Test pada satu artikel gratis dan satu artikel premium, dan pastikan isAccessibleForFree serta hasPart terbaca dengan benar. Terakhir, periksa bahwa cssSelector yang kamu tulis benar-benar cocok dengan elemen di HTML yang dilihat pembaca anonim.

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