Saya sedang mengaudit crawl budget sebuah situs konten yang saya bangun dengan Next.js App Router, dan Google Search Console memberi sinyal yang aneh: puluhan URL artikel, koin, dan glossary yang sudah lama saya hapus masih nangkring di indeks, dan crawler terus balik ke sana. Padahal halamannya jelas-jelas menampilkan UI not-found. Refleks saya: pasti ada sitemap basi atau internal link nyasar. Ternyata bukan itu. Yang saya cek pertama justru status code-nya:
curl -sI https://old-site.com/article/artikel-yang-sudah-dihapus | head -n 1Yang balik bikin saya diam sebentar:
HTTP/2 200
Dua ratus. Bukan 404. Halaman not-found saya, dengan segala teks "halaman tidak ditemukan"-nya, dikirim ke Google dengan status HTTP 200. Itu definisi buku teks dari soft-404: halaman yang secara visual bilang "kosong" tapi secara protokol bilang "semuanya baik-baik saja, silakan indeks". Google jelas lebih percaya status code daripada teks, jadi URL-URL mati itu tidak akan pernah ter-deindex, dan crawl budget saya kebuang buat halaman hantu.
Kenapa ini terjadi
Route yang bermasalah semuanya force-dynamic, alias dirender lewat RSC streaming. Dan di situlah jebakannya. Begitu Next.js mulai men-stream respons, dia langsung commit status HTTP 200 ke header, karena byte pertama sudah keburu dikirim ke klien. Header HTTP tidak bisa diubah setelah body mulai mengalir. Itu aturan protokol, bukan kebijakan Next.js.
Masalahnya, notFound() di route dinamis saya dipanggil setelah sebuah await untuk fetch data:
export default async function ArticlePage({ params }) {
const article = await getArticle(params.slug)
if (!article) {
notFound() // sudah terlambat: 200 sudah ter-commit
}
return <Article data={article} />
}Saat notFound() akhirnya jalan, streaming sudah dimulai, 200 sudah dikirim, dan yang bisa dilakukan Next.js cuma me-render UI not-found di dalam body yang statusnya sudah terlanjur 200. Saya sempat mengira menghapus loading.tsx bakal menolong, karena itu yang biasanya memicu streaming lebih awal. Ternyata tidak. Selama page itu sendiri masih await data sebelum memutuskan not-found, stream tetap jalan duluan. Menghapus loading.tsx tidak mengubah apa-apa.
Jadi ini bukan bug di kode saya, dan bukan cache yang nyangkut. Ini konsekuensi arsitektur dari streaming: status harus dikirim sebelum kamu tahu jawabannya not-found.
Perbaikannya
Kunci logikanya: kalau saya butuh status code yang benar, saya harus memutuskan status sebelum streaming dimulai. Satu-satunya tempat yang jalan sebelum render adalah middleware.ts. Middleware berjalan di edge, sebelum route handler mana pun, dan dia bisa commit status sekali dan selesai. Jadi untuk slug yang saya tahu pasti mati, saya balikkan HTTP 410 Gone langsung dari middleware:
// middleware.ts
import { NextResponse } from 'next/server'
import { GONE_ARTICLE_SLUGS } from './lib/gone-article-slugs'
export function middleware(request) {
const slug = request.nextUrl.pathname.split('/').pop() ?? ''
if (GONE_ARTICLE_SLUGS.has(slug)) {
return new NextResponse('Gone', {
status: 410,
headers: { 'Cache-Control': 's-maxage=3600' },
})
}
return NextResponse.next()
}Saya sengaja pakai 410 Gone, bukan 404, karena 410 memberi tahu Google bahwa halaman ini memang sengaja dihilangkan permanen. Deindex-nya lebih cepat dan lebih tegas ketimbang 404.
Ada satu keputusan desain yang saya tekankan ke diri sendiri di sini, dan ini penting: daftar slug matinya statis, di lib/gone-article-slugs.ts, bukan hasil query ke CMS. Godaannya besar untuk menulis "kalau sanityFetch balik null, kirim 410". Jangan. Karena sanityFetch balik null untuk dua kondisi yang sama sekali berbeda: artikel memang tidak ada, ATAU ada kegagalan transien (jaringan, rate limit, CMS down sebentar). Kalau saya menuruti null, satu kedipan jaringan bisa mengirim 410 untuk artikel yang sebenarnya hidup, dan Google akan men-deindex konten asli. Daftar statis tidak punya risiko itu.
Untuk route not-found umum yang bukan slug-mati-yang-diketahui, saya biarkan tetap sebagai soft-404 yang protektif. Membiarkan halaman acak balik 200 jauh lebih aman daripada mengambil risiko men-deindex sesuatu yang valid karena logika yang terlalu agresif.
Pelajaran
Di dunia RSC streaming, notFound() setelah await tidak akan pernah menghasilkan 404 asli, karena status HTTP sudah ter-commit begitu byte pertama mengalir. Kalau kamu butuh status code yang benar untuk SEO, putuskan sebelum streaming: kirim 410 (atau 404) dari middleware.ts untuk daftar slug yang statis dan pasti mati. Jangan pernah menyetir status itu dari lookup CMS, karena null yang ambigu bisa men-deindex halaman hidup. Dan jangan percaya bahwa halaman not-found kamu benar hanya karena teksnya benar; curl -sI dan lihat baris pertama. Sebuah UI yang bilang "tidak ditemukan" di atas HTTP 200 bukan 404, itu janji palsu ke crawler.
