D
P
0
← Semua artikel Read in English

SEO Teknis, Redirect & Migrasi

Canonical dan `<title>` Nyasar ke `<body>` di Next.js 16? Googlebot Tidak Ada di Daftar `htmlLimitedBots`

· · 10 menit baca
Canonical dan `<title>` Nyasar ke `<body>` di Next.js 16? Googlebot Tidak Ada di Daftar `htmlLimitedBots`

Selama meta homepage di proyek ini ditulis sebagai export const metadata statis, title dan canonical-nya selalu berada di dalam <head>. Pada 14 September 2026 meta itu saya pindahkan ke CMS supaya tim SEO klien bisa menyuntingnya sendiri, dan sejak itu generateMetadata homepage menunggu query ke dokumen pengaturan homepage. Proyeknya memakai Next.js 16, dan perubahan sekecil itu ternyata cukup untuk mendorong title, description, canonical, dan tag Open Graph ke dalam body. Kebocorannya tidak terjadi di setiap request. Dia hanya muncul pada satu request tertentu, dan request itu tidak akan kamu temui kalau yang diperiksa adalah halaman yang sudah hangat.

Hanya request pertama sesudah webhook

Polanya sempit. Setiap kali webhook terbit masuk, entah yang terbit sebuah artikel atau dokumen pengaturan homepage itu sendiri, request homepage pertama sesudahnya membawa title, description, canonical, dan tag Open Graph sekitar 40 KB ke dalam body. Itu yang terjadi untuk user agent Googlebot. Request berikutnya, ketika cache sudah hangat, bersih lagi. Artikel di situs ini terbit beberapa kali sehari, jadi perkiraan saya, kalau dibiarkan, kebocoran ini akan berulang setiap hari.

Angka 40 KB itu saya ukur di build produksi lokal, next build lalu next start, dengan webhook bertanda tangan yang saya kirim sendiri. Cara mengulangnya ada di bagian pengukuran di bawah.

Googlebot tidak ada di daftar bawaan

Next 16 men-stream metadata ke setiap user agent yang tidak cocok dengan htmlLimitedBots, dan Googlebot tidak ada di daftar bawaannya. Ketika perubahan konfigurasi yang saya ceritakan nanti dibuat pada 15 September, package.json proyek ini masih mengunci Next 16.2.4. Potongan kode Next di bawah saya baca dari 16.3.6, versi yang terpasang di repo itu sekarang, dan di versi itu logikanya terbaca jelas:

// next/dist/server/lib/streaming-metadata.js (Next 16.3.6), dipangkas
function shouldServeStreamingMetadata(userAgent, htmlLimitedBots) {
    const pattern = htmlLimitedBots || _isbot.HTML_LIMITED_BOT_UA_RE_STRING;
    // ...
    // Only block metadata for HTML-limited bots
    if (userAgent && cachedRegex.test(userAgent)) {
        return false;
    }
    return true;
}
 
// next/dist/shared/lib/router/utils/html-bots.js (Next 16.3.6)
const HTML_LIMITED_BOT_UA_RE = /[\w-]+-Google|Google-[\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight/i;
 
// next/dist/shared/lib/router/utils/is-bot.js (Next 16.3.6), dipangkas
// Bot crawler that will spin up a headless browser and execute JS.
// Only the main Googlebot search crawler executes JavaScript, not other Google crawlers.
const HEADLESS_BROWSER_BOT_UA_RE = /Googlebot(?!-)|Googlebot$/i;

Fungsi itu mengembalikan false, yang berarti metadata dirender secara blocking dan diselesaikan sebelum shell dikirim, hanya untuk user agent yang cocok dengan pola. Semua yang lain menerima metadata yang di-stream. Kata Googlebot tidak muncul di daftar bawaan, begitu juga Screaming Frog. Googlebot malah dikenali lewat regex tersendiri, dengan komentar yang menyebutnya crawler yang menyalakan headless browser dan menjalankan JavaScript.

Masalahnya, tidak semua yang membaca HTML menjalankan JavaScript. Komentar yang saya tinggalkan di kode homepage mencatat kekhawatiran itu: crawler yang tidak menjalankan JavaScript, misalnya Screaming Frog yang dipakai tim SEO klien untuk audit, akan membaca homepage tanpa title.

Revalidasi membuat metadata kalah balapan

Streaming saja belum cukup untuk memindahkan tag. Metadata yang di-stream tetap mendarat di <head> kalau dia sudah selesai sebelum shell di-flush, yaitu sebelum potongan HTML pertama yang memuat head dikirim. Saat cache hangat, query meta homepage selesai tepat waktu. Webhook yang mengubah urutannya: revalidateTag(type, "max") ditambah revalidatePath("/") membuat render berikutnya menunggu jaringan, dan metadata kalah balapan. Route webhook-nya, dalam bentuk ringkas dan dengan nama set yang saya ganti, kira-kira seperti ini:

// route webhook, disederhanakan; nama set diganti
revalidateTag(body._type, "max");
 
// ...
 
if (!TYPES_NOT_ON_HOMEPAGE.has(body._type)) {
  revalidatePath("/");
}

Setiap tipe dokumen yang tidak dikecualikan ikut menyegarkan homepage, jadi request homepage pertama sesudah webhook itulah yang harus menunggu jaringan. Mekanisme yang sama menjelaskan sebagian dari tiga butir pertama audit SEO yang dibuat tim SEO klien pada bulan Mei: rute dinamis mana pun dengan metadata async akan bocor pada render pertama sesudah path-nya direvalidasi, bukan hanya halaman arsip yang sedang dingin.

Kalau kamu pernah membaca tulisan saya soal notFound() yang tetap menghasilkan HTTP 200, kasus ini kerabatnya, tapi masalahnya lain. Di sana status terlanjur terkirim sebelum halaman tahu jawabannya. Di sini masalahnya bukan status. Semua tag-nya ada, hanya letaknya yang meleset. Untuk canonical, letak itu bukan urusan kosmetik, karena dokumentasi Google menyebut rel="canonical" hanya diterima kalau berada di <head>.

Mengukur tanpa tertipu halaman hangat

Aturan yang saya catat dari kejadian ini: jangan pernah menilai letak metadata dari halaman yang sudah hangat. Urutannya next build, next start, request sebagai Googlebot, tembakkan webhook bertanda tangan, request lagi, lalu bandingkan offset <title> dengan offset </head>.

Di proyek ini secret webhook di .env.local kosong, jadi secret-nya saya berikan lewat environment proses saat server dinyalakan. Kredensial Cloudflare saya isi nilai dummy supaya route webhook tidak bisa mem-purge Cloudflare produksi. Tanda tangannya berbentuk t=<ms>,v1=<base64url HMAC-SHA256(secret, "<t>.<body>")> di header yang namanya mengikuti CMS, dan route-nya menunggu sekitar 3 detik demi eventual consistency. Kalau mau mengulang rangkaian itu di proyekmu sendiri, skrip curl sederhana seperti ini sudah cukup:

# disederhanakan; nama variabel, header, dan URL webhook di sini netral.
# Server sudah jalan lewat next build lalu next start, dengan secret yang sama.
WEBHOOK_SECRET="secret-lokal"
WEBHOOK_URL="http://localhost:3000/..."   # route webhook revalidasi milikmu
SIGNATURE_HEADER="..."                     # nama header tanda tangan dari CMS-mu
GOOGLEBOT="Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
 
offset() {
  curl -s -A "$GOOGLEBOT" http://localhost:3000/ \
    | grep -Ebo '<title>|</head>|rel="canonical"'
}
 
offset   # request pembanding
 
T=$(( $(date +%s) * 1000 ))
BODY='{"_type":"post"}'   # sesuaikan dengan payload webhook CMS-mu
SIG=$(printf '%s.%s' "$T" "$BODY" \
  | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -binary \
  | base64 | tr '+/' '-_' | tr -d '=')
curl -s -X POST "$WEBHOOK_URL" \
  -H 'content-type: application/json' \
  -H "$SIGNATURE_HEADER: t=$T,v1=$SIG" \
  -d "$BODY"
 
offset   # request pertama sesudah revalidasi, yang ini yang menentukan

Dengan -o, grep -b mencetak offset byte setiap kecocokan. Kalau angka di depan <title> lebih besar daripada angka di depan </head>, title itu ada di body.

Homepage dulu: jawab dari memori

Perbaikan pertama hanya untuk homepage dan masuk produksi hari itu juga. generateMetadata tidak lagi menunggu query. Dia mengembalikan nilai yang disimpan di memori per proses server, lalu menyegarkannya dengan after(). Bentuk ringkasnya, dengan nama fungsi yang sudah saya ganti:

// disederhanakan, nama diganti
// refresh() mengambil meta dari CMS lalu menyimpannya ke latest
let latest: HomeMeta | null = null;
 
export async function readHomeMeta(): Promise<HomeMeta> {
  if (!latest) return refresh(); // request pertama sebuah proses menunggu
  after(refresh);                // penyegaran berjalan sesudah respons
  return latest;                 // nilai terakhir, dikembalikan seketika
}

Komentar di modul aslinya merangkum tukarannya. Nilai terakhir disimpan per proses dan dikembalikan seketika, jadi selalu mengalahkan shell, sementara penyegarannya berjalan sesudah respons. Harganya, suntingan di CMS tidak terlihat pada request yang memicu penyegaran, baru pada request sesudahnya. Hanya request homepage paling pertama yang dilayani sebuah proses yang masih menunggu query, dan di situlah sisa risikonya: request pertama dari proses yang baru menyala masih bisa bocor. Pola memo seperti ini punya jebakan sendiri soal draft mode, dan itu saya tulis terpisah.

Di build lokal sesudah perubahan, 24 dari 24 request yang menyusul 6 webhook menaruh metadata di head. Rilisnya hidup di produksi pukul 08:34Z, dan pemeriksaan di sana menemukan meta homepage di dalam head pada 12 dari 12 request.

Halaman detail aset bocor di setiap render

Homepage sudah tertangani, tapi rute dinamis lain masih bisa menaruh title dan canonical di luar head pada render pertama sesudah revalidasi. Perbaikan yang menyeluruh untuk itu ada di htmlLimitedBots di next.config, dan itu mengubah perilaku SEO di semua halaman. Keputusan semacam itu milik tim SEO klien, bukan saya, jadi pada 15 September butir ini saya catat dulu tanpa dikerjakan.

Hari itu juga tim SEO klien meminta perbaikannya, sesudah crawl mereka menandai canonical di luar head pada dua template halaman detail aset. Masalah ini ternyata bukan barang baru. Dia sudah tercatat sebagai butir pertama audit SEO mereka bulan Mei.

Pengukuran di produksi pada 15 September memperlihatkan skala yang berbeda dari homepage. Di kedua template itu, canonical dan title mendarat di body pada setiap render. Head sudah ditutup di sekitar 2,2 KB, sementara canonical baru muncul di antara 45 dan 106 KB, untuk setiap user agent yang saya coba dan di setiap keadaan cache. generateMetadata di sana menunggu data aset, rutenya di-stream, dan Googlebot tidak ada di daftar bawaan. Delapan template lain yang saya ukur, termasuk homepage, artikel, dan kategori, menaruh keduanya di head.

Pola kustom mengganti daftar bawaan

Opsi pertama yang saya tawarkan adalah daftar bot, htmlLimitedBots: /Googlebot|bingbot|.../, supaya crawler mendapat metadata blocking dan pembaca tetap mendapat streaming. Yang akhirnya dipasang justru mencocokkan semua user agent. Alasannya ada di baris pertama shouldServeStreamingMetadata tadi: pola dari konfigurasi dipakai sebagai pengganti daftar bawaan, bukan tambahan. Begitu kamu menulis pola sendiri, setiap nama di daftar bawaan yang tidak kamu tulis ulang, misalnya facebookexternalhit, Twitterbot, atau Slackbot, berhenti mendapat metadata blocking. Mencocokkan semuanya jadi pilihan yang paling bisa diprediksi.

// next.config.ts, dipangkas
const nextConfig: NextConfig = {
  // Metadata is rendered blocking for every request, so <title>, canonical
  // and robots are always inside <head>.
  htmlLimitedBots: /.*/,
};

Commit-nya hanya mengubah berkas itu, dengan 12 baris tambahan. Yang dilepas adalah streaming metadata untuk pembaca, dan pesan commit-nya memberi alasan: body di rute-rute itu toh menunggu data cache yang sama, jadi pembaca tidak kehilangan apa pun yang terukur. Di build lokal sesudah perubahan, TTFB halaman aset 0,14 detik, dan canonical serta title berada di head pada kedua template aset, homepage, artikel, dan satu template lain, untuk Googlebot maupun Chrome. Slug yang tidak dikenal tetap menjawab 200 dengan body not-found, jadi statusnya tidak berubah dan tidak muncul risiko 404 keras baru ketika CMS sedang tersendat.

Cloudflare masih memegang salinan yang bocor

Perubahan itu hidup di produksi pukul 08:49Z tanggal 15 September. Pemeriksaan dengan cache-buster sebagai Googlebot, Screaming Frog, dan Chrome menemukan canonical dan title di dalam head pada dua halaman dari template aset pertama, satu dari template kedua, homepage, dan satu artikel. Catatan pengukuran sesudah perubahan menyebut hal yang sama untuk setiap template dan ketiga user agent itu.

Cache-buster itu bukan formalitas. Cloudflare masih memegang salinan lama halaman aset yang bocor ke body, dengan TTL 30 menit. Saya purge per URL untuk 15 halaman teratas dari template pertama, 7 dari template kedua, dan homepage, total 23 URL, lalu membiarkan ekor panjangnya segar sendiri dalam 30 menit. Catatan yang saya tulis untuk lain kali: CDN menyimpan salinan yang bocor sampai TTL-nya habis, jadi purge URL yang akan dicek ulang tim SEO.

Butir audit dari Mei itu dinyatakan selesai. Memo after() di homepage boleh tetap terpasang dan sekarang jadi lapisan pengaman kedua, karena dengan metadata blocking untuk semua request, request homepage pertama sesudah webhook pun tidak lagi bisa membocorkan title ke body.

Yang terbaca sesudahnya, termasuk di Search Console

Pemeriksaan saya sendiri pada 23 September menemukan title dan description homepage di dalam head pada 5 dari 5 fetch dengan user agent Googlebot, dengan canonical yang menunjuk domain utama.

Pada 24 September, URL Inspection di Search Console untuk sebuah URL yang di-crawl pada 9 Juli menampilkan "user-declared canonical: none". Penjelasan yang saya catat waktu itu: sampai perubahan 15 September, metadata untuk Googlebot di-stream ke body, dan crawl itu terjadi jauh sebelumnya. Pada live test di hari yang sama, fetch berhasil, hasilnya dinyatakan bisa diindeks, dan canonical terbaca.

Ada satu hal yang membuat alat ini perlu dibaca hati-hati. URL Inspection memakai user agent Google-InspectionTool, dan nama itu cocok dengan pola Google-[\w-]+ di daftar bawaan Next, sedangkan Googlebot tidak. Dugaan saya, live test di URL Inspection bisa menampilkan head yang bersih sementara Googlebot sendiri menerima metadata yang di-stream. Ini belum sesuatu yang saya ukur, jadi anggap sebagai peringatan. Kalau dugaan itu benar, live test yang bersih tidak banyak bercerita tentang apa yang diterima Googlebot sebelum perbaikan.

Yang belum saya pegang adalah bukti dampaknya pada indeks. Tidak ada angka berapa halaman yang terindeks berkat perubahan ini, dan tidak ada bukti bahwa Google kini memilih canonical yang dideklarasikan. Yang sudah terbukti baru bahwa HTML yang disajikan ke Googlebot membawa tag-tag itu di tempat yang benar.

Sebelum menyimpulkan metadata sudah di head

Kalau generateMetadata di proyek Next 16-mu menunggu data, empat hal ini yang saya catat dari kasus tadi sebelum percaya bahwa tag-tagnya ada di tempat yang benar.