Permintaannya datang dari sisi SEO klien, bukan dari saya: feed RSS situsnya harus bisa ditemukan orang. Waktu saya buka rutenya, ada dua hal yang salah sekaligus. /rss.xml memang hidup dan balik 200, tapi yang dia sajikan adalah artikel mockup dari modul konten contoh, bukan berita asli dari CMS. Dan tidak ada satu pun halaman di situs itu yang menautkannya. Feed yang isinya palsu, dan yang mau memakainya pun tidak akan tahu dia ada.
Situsnya berjalan di atas Next.js 16, jadi link penemuan feed-nya nanti dipasang lewat Metadata API.
Tahap pertama: bikin feed-nya benar-benar ada
Bagian ini lurus. Saya sambungkan rute feed ke sumber data yang sama dengan halaman listing, lewat getLatestPosts(50), saya tambahkan link atom:self di dalam dokumennya, lalu saya pasang link alternate RSS sitewide lewat alternates.types di metadata root layout.
// root layout
export const metadata = {
alternates: {
types: {
"application/rss+xml": "/rss.xml",
},
},
};Efeknya kelihatan sampai ke output build. Rute feed-nya berubah status jadi Dynamic, sebelumnya dia placeholder statis. Datanya saya verifikasi hidup: 50 item asli, dan atom:self ada di tempatnya.
Sampai titik ini saya pikir pekerjaannya selesai. Feed-nya nyata, dan link penemuannya sudah dideklarasikan di satu tempat yang berlaku untuk seluruh situs. Persis pola yang biasa dipakai orang untuk hal semacam ini.
Yang deklarasinya sitewide, kenyataannya tidak
Link alternate itu ternyata dibuang di setiap halaman yang memasang metadata sendiri. Yang tersisa hanya halaman yang tidak menimpa apa pun.
Tidak ada yang meledak waktu itu, dan memang tidak akan ada. Link head tidak punya wujud di layar, tidak memicu error, dan tidak mengubah apa pun yang bisa dilihat pengunjung. Satu-satunya cara tahu adalah membuka <head> halaman yang bersangkutan dan mencarinya sendiri. Kalau yang kamu buka kebetulan halaman yang tidak memasang metadata sendiri, link-nya ada, dan kamu pulang dengan kesimpulan yang salah.
Akar masalahnya: objek alternates diganti utuh
metadata.alternates milik halaman menimpa milik layout. Bukan deep-merge, tapi ganti. Objek yang lebih dalam menang seluruhnya, dan isi objek yang di atasnya tidak ikut dibawa.
Yang membuat ini menggigit di semua halaman sekaligus adalah helper metadata bersama yang sudah lama saya pakai. Helper itu memang tugasnya memasang canonical, dan canonical-nya sudah bebas parameter, ditulis sebagai alternates.canonical = path:
// lib/metadata.ts, sebelum diperbaiki
export function buildMeta({ title, description, path }) {
return {
title,
description,
alternates: {
canonical: path,
},
};
}Jadi tiap halaman yang memanggil helper ini mengirim sebuah objek alternates yang isinya cuma canonical. Objek itulah yang menang. { canonical } dari helper menggilas { types } milik layout, di setiap halaman, tanpa suara. Bukan karena ada yang menulis kode untuk menghapusnya, tapi karena dua objek yang berbagi kunci yang sama tidak pernah dilebur.
Field tetangganya justru diwarisi
Bagian yang bikin ini gampang salah tebak: di objek metadata yang sama, title berperilaku sebaliknya. Template judul di root layout, yang bentuknya %s | Brand, justru menyusun diri dengan judul milik halaman. Di projek yang sama saya sampai perlu jalan keluar eksplisit untuk itu, karena judul kustom yang datang dari sisi klien panjangnya sudah dipas tanpa nama merek, jadi halaman-halaman itu harus meminta judul absolut supaya sufiks templatenya tidak ikut menempel.
// root layout
export const metadata = {
title: {
template: "%s | Brand",
default: "Brand",
},
};
// halaman yang judulnya tidak boleh kena sufiks
export const metadata = {
title: { absolute: "Judul yang panjangnya sudah dipas" },
};Dua field, dua aturan. title menggabungkan nilai layout dengan nilai halaman sampai kamu harus repot-repot memilih keluar. alternates tidak menggabungkan apa pun, dia cuma bertukar. Yang salah dari dugaan saya bukan detail satu field, tapi asumsi bahwa pewarisan metadata itu berlaku seragam untuk seluruh objeknya.
Perbaikannya: pancarkan dari tempat yang sama dengan canonical
Begitu tahu objeknya diganti utuh, perbaikannya jadi sederhana dan agak membosankan: pindahkan link types RSS ke dalam objek yang pasti menang. Itu helper yang sama, tepat di sebelah canonical.
// lib/metadata.ts, sesudah diperbaiki
export function buildMeta({ title, description, path }) {
return {
title,
description,
alternates: {
canonical: path,
types: {
"application/rss+xml": "/rss.xml",
},
},
};
}Sekarang link RSS-nya ikut ke mana pun canonical ikut, karena keduanya berangkat dari objek yang sama. Hasilnya saya verifikasi hidup di produksi, link head-nya ada di homepage dan di rute detail dinamis.
Pelajaran
Untuk link head yang sifatnya sitewide lewat Metadata API, tempatnya bukan hanya di root layout, tapi di helper per halaman yang dipakai bersama, tempat yang sama yang memasang canonical. Alasannya satu kalimat: Next menimpa seluruh objek alternates per halaman, jadi apa pun yang cuma ada di layout akan lenyap begitu halaman mengisi objek itu dengan versinya sendiri.
Kalau kamu memverifikasi link semacam ini, pilih halaman yang memasang metadata sendiri, bukan halaman yang tidak menimpa apa pun. Halaman kedua akan selalu memberi jawaban yang menyenangkan dan tidak berarti apa-apa.
Pola yang sama sudah saya temui di dua projek, dan bentuknya selalu mirip: sesuatu yang dideklarasikan sekali di root, dianggap berlaku di mana-mana, lalu diam-diam dibatalkan oleh objek yang lebih dalam.