D
P
0

Technical SEO

`generateSitemaps` di Next.js Malah Balik `<urlset>` Kosong, Bukan `<sitemapindex>`? Konvensinya Memang Tidak Bisa

10 Juli 2026·4 menit baca
`generateSitemaps` di Next.js Malah Balik `<urlset>` Kosong, Bukan `<sitemapindex>`? Konvensinya Memang Tidak Bisa

Situs yang saya kerjakan punya ribuan halaman, jadi satu sitemap tunggal tidak cukup. Rencananya klasik: satu sitemap index di root yang menunjuk ke beberapa sub-sitemap per section. Next.js punya konvensi generateSitemaps yang sepertinya persis untuk kasus ini, jadi saya pakai itu, deploy, lalu cek dari terminal seperti biasa:

curl https://site.example/sitemap.xml

Responnya HTTP 200. Content-type-nya application/xml. Semua indikator kasar tampak hijau. Tapi begitu saya baca isinya, ada yang salah. Yang saya harapkan adalah dokumen sitemapindex dengan daftar sitemap yang menunjuk ke sub-sitemap. Yang saya dapat malah <urlset></urlset> kosong. Bukan index, bukan error, cuma urlset yang tidak berisi satu URL pun.

Gejalanya menipu

Yang bikin ini menyebalkan adalah tidak ada satu pun sinyal kegagalan yang biasa. Status 200 bikin monitoring saya diam. Content-type yang benar bikin saya percaya route-nya berjalan. Kalau saya cuma ngecek curl -I dan lihat 200, saya akan pikir sitemap ini beres dan lanjut. Tepat jenis bug yang lolos review karena "kan sudah 200".

Refleks pertama saya jelek: saya kira generateSitemaps saya salah data, mungkin query yang balik array kosong sehingga tidak ada URL yang masuk. Saya log jumlah entri per sitemap, dan angkanya benar, ribuan URL. Datanya ada. Tapi root /sitemap.xml tetap balik urlset kosong. Section-nya terisi, index-nya tidak. Itu petunjuk pertama bahwa masalahnya bukan di data, tapi di bentuk dokumen yang Next.js mau hasilkan.

Akar masalahnya

Begitu saya berhenti menyalahkan data dan mulai membaca apa yang sebenarnya di-serialize Next.js, semuanya klik. Tipe MetadataRoute.Sitemap di Next.js itu sebuah array dari entri berbentuk { url, lastModified, ... }. Apa pun yang kamu kembalikan dari fungsi sitemap, Next.js akan meng-serialize-nya menjadi satu dokumen <urlset>. Selalu. Tidak ada varian tipe, tidak ada opsi, tidak ada flag yang membuatnya meng-emit skema <sitemapindex>.

generateSitemaps pun tidak mengubah ini seperti yang saya kira. Konvensi itu membantu memecah satu urlset besar menjadi beberapa urlset yang lebih kecil, misalnya /sitemap/0.xml, /sitemap/1.xml, dan seterusnya. Tapi parent /sitemap.xml tetaplah fungsi default yang mengembalikan sebuah urlset. Tidak ada titik di dalam konvensi ini di mana kamu bisa bilang ke Next.js "yang ini bukan urlset, ini index yang menunjuk ke urlset-urlset lain".

Jadi bukan datanya yang salah, dan bukan pula deploy yang gagal. Saya cuma memakai konvensi yang secara desain tidak bisa menghasilkan bentuk dokumen yang saya butuhkan. Sitemap index dan urlset itu dua skema XML yang berbeda, dan konvensi MetadataRoute.Sitemap cuma tahu satu di antaranya.

Perbaikannya

Solusinya adalah berhenti melawan konvensi dan menuliskan XML-nya sendiri lewat route handler manual. Root meng-emit sitemapindex, tiap section meng-emit urlset, dan builder XML-nya saya taruh di satu file bersama supaya tidak ada duplikasi.

Langkah pertama, dan ini penting, hapus dulu app/sitemap.ts. Selama file itu ada, ia akan bertabrakan dengan route handler manual saya di path yang sama dan Next.js akan tetap menyajikan urlset dari konvensi. Ini biang keladi kenapa fix saya awalnya tidak berpengaruh.

// lib/sitemap-helpers.ts
export function sitemapIndexXml(sitemaps: { loc: string; lastmod?: string }[]) {
  const items = sitemaps
    .map((s) => `<sitemap><loc>${s.loc}</loc>${s.lastmod ? `<lastmod>${s.lastmod}</lastmod>` : ""}</sitemap>`)
    .join("");
  return `<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${items}</sitemapindex>`;
}
 
export function urlSetXml(urls: { loc: string; lastmod?: string }[]) {
  const items = urls
    .map((u) => `<url><loc>${u.loc}</loc>${u.lastmod ? `<lastmod>${u.lastmod}</lastmod>` : ""}</url>`)
    .join("");
  return `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${items}</urlset>`;
}

Root route meng-emit index yang menunjuk ke tiap section:

// app/sitemap.xml/route.ts
import { sitemapIndexXml } from "@/lib/sitemap-helpers";
 
export async function GET() {
  const base = "https://site.example";
  const sections = ["posts", "products", "pages"];
  const xml = sitemapIndexXml(
    sections.map((name) => ({ loc: `${base}/sitemap/${name}.xml` }))
  );
  return new Response(xml, { headers: { "Content-Type": "application/xml" } });
}

Tiap section punya route dinamis yang meng-emit urlset-nya sendiri:

// app/sitemap/[name].xml/route.ts
import { urlSetXml } from "@/lib/sitemap-helpers";
import { getUrlsForSection } from "@/lib/data";
 
export async function GET(_req: Request, { params }: { params: { name: string } }) {
  const urls = await getUrlsForSection(params.name);
  const xml = urlSetXml(urls);
  return new Response(xml, { headers: { "Content-Type": "application/xml" } });
}

Sekarang /sitemap.xml balik sitemapindex sungguhan, dan tiap /sitemap/<section>.xml balik urlset yang terisi.

Cara verifikasi yang benar

Pelajaran besar dari bug ini bukan cuma soal Next.js, tapi soal cara saya memverifikasi. Status 200 dengan application/xml itu tidak cukup. Keduanya bisa benar sementara isi dokumennya salah total. Jadi sekarang saya grep bentuknya secara eksplisit:

curl -s https://site.example/sitemap.xml | grep -o '<sitemapindex' && echo "index OK"
curl -s https://site.example/sitemap/posts.xml | grep -o '<urlset' && echo "urlset OK"

Kalau grep untuk <sitemapindex> tidak balik apa-apa di root, sitemap-nya salah bentuk meskipun status dan header-nya sempurna.

Checklist

  • Hapus dulu app/sitemap.ts, kalau tidak ia bertabrakan dan tetap menyajikan urlset konvensi.
  • Ingat MetadataRoute.Sitemap selalu meng-emit <urlset>, tidak pernah <sitemapindex>.
  • Buat app/sitemap.xml/route.ts untuk meng-emit index dengan referensi <sitemap><loc>.
  • Buat app/sitemap/[name].xml/route.ts untuk meng-emit urlset per section.
  • Taruh builder XML di lib/sitemap-helpers.ts supaya index dan urlset berbagi kode yang sama.
  • Verifikasi dengan curl lalu grep untuk <sitemapindex>, jangan cuma percaya 200 dan content-type.