Migrasi ini dimulai dari satu daftar, dan daftar keduanya yang membuat aturan pertama jadi salah. SEO lead di sisi klien mengirim 1.558 URL dari situs WordPress lama yang punya trafik atau backlink dan harus tetap resolve di situs baru: 1.344 entri glosarium di /terms/{slug}/ dan 214 post blog di /posts/{slug}/. Situs barunya Next.js 16 dengan App Router, dan untuk post blog keputusan awalnya sederhana: 301 dari /posts/[slug] ke /stories/[slug] dengan slug dipertahankan, dipasang di next.config.ts, dengan alasan backlink eksternal meneruskan kira-kira 95% link equity lewat 301.
Bentuknya kurang lebih seperti ini:
// next.config.ts, versi awal
const nextConfig = {
async redirects() {
return [
{ source: "/posts/:slug", destination: "/stories/:slug", permanent: true },
];
},
};Aturan itu ikut dalam tiga commit pertama yang di-push dan ter-deploy ke staging.
CSV kedua membalik asumsinya
Lalu CSV lanjutan datang dari SEO lead yang sama, 3.733 baris. Isinya menetapkan bahwa semua URL /posts/*, /quote/*, /venue/*, /press/* dan sejenisnya yang tidak dipertahankan harus 301 ke halaman kategori atau pillar, bukan ke padanan slug-nya. Kolomnya tiga, URL,Action,Target URL, semua Action bernilai 301, dan target URL-nya berprefiks domain staging yang diminta dibuang.
CSV-nya tidak dibaca saat runtime. Skrip build terpisah memprosesnya jadi dua artefak JSON yang di-commit, sementara CSV-nya sendiri di-gitignore. Artefak pertama adalah map { pathname: targetPathname } berisi 3.725 path unik, dedup per path dengan query string dibuang supaya varian fbclid runtuh ke satu entri, dan path yang masuk daftar KEEP disaring saat build. Ke-3.725 path itu jatuh ke enam target saja: 1.921 ke /beginner-guide, 1.246 ke /markets/assets, 544 ke /markets/venues, 12 ke /, dan masing-masing satu ke /trend-analysis dan /protocol-basics. Jumlahnya 1.921 + 1.246 + 544 + 12 + 1 + 1 = 3.725, cocok. Artefak kedua adalah Set 213 slug artikel KEEP, satu lebih sedikit dari 214 di daftar awal, karena hasil scrape final tercatat 213, dan inilah yang menggerakkan redirect /posts/{slug} ke /stories/{slug} hanya untuk artikel yang dipertahankan, bukan untuk 1.636 yang dialihkan ke tempat lain.
Middleware lalu dapat dua tanggung jawab baru, berurutan: kalau path /posts/{slug} dan slug-nya ada di Set KEEP, 301 ke /stories/{slug}; kalau tidak, cari pathname di map dan 301 ke targetnya. Pencocokannya path saja, trailing slash dan query string dinormalisasi dulu. Disederhanakan, urutannya kira-kira begini:
// middleware.ts, urutan prioritasnya
const path = normalize(req.nextUrl.pathname); // trailing slash dan query dibuang
const keep = path.match(/^\/posts\/([^/]+)$/);
if (keep && keepSlugs.has(keep[1])) {
return NextResponse.redirect(new URL(`/stories/${keep[1]}`, req.url), 301);
}
const target = oldUrlMap[path];
if (target) {
return NextResponse.redirect(new URL(target, req.url), 301);
}Satu target dari CSV klien, /trend-analysis, belum ada sebagai route dan harus ditambahkan sebagai pillar. Dua target lainnya, /beginner-guide dan /protocol-basics, sudah ada.
Tabelnya benar, hasilnya tetap salah
Begitu dites, /posts/{slug} untuk slug yang tidak dipertahankan tetap diarahkan ke /stories/{slug}, dan halaman itu 404. Seharusnya dia mendarat di /beginner-guide, dan tabel lookup di middleware sudah memetakannya ke sana dengan benar.
Kenapa ini terjadi
redirects() di next.config.ts dijalankan sebelum middleware dalam urutan Next.js. Aturan blanket /posts/:slug ke /stories/:slug yang dipasang di awal masih ada di sana, dan dia menangkap setiap request /posts/* sebelum middleware sempat melihatnya. Lookup selektif untuk 3.725 path itu tidak pernah dapat giliran.
Yang menyesatkan, tidak ada yang error. Middleware-nya benar, map-nya benar, Set KEEP-nya benar. Yang salah adalah ada dua lapisan yang sama-sama mengklaim /posts/*, dan lapisan yang tidak selektif kebetulan berdiri di depan.
Perbaikannya
Aturan blanket dibuang dari next.config.ts sepenuhnya, dan semua logika /posts/* pindah ke middleware, tempat dia bisa selektif: cek Set KEEP dulu, baru map redirect. Batch yang masuk commit itu lima berkas: middleware.ts, next.config.ts, skrip build redirect, dan dua artefak JSON-nya. Build hygiene tetap bersih sepanjang sesi, tsc nol galat, lint nol masalah, 31 dari 31 tes lulus. Auto-deploy ke staging sukses, dan build barunya saya verifikasi malam 1 Mei 2026 lewat pemeriksaan HTML mentah setelah purge cache CDN.
Memindahkan redirect ke middleware juga bukan pemakaian middleware pertama di projek ini. Dia sudah lebih dulu dipakai untuk memblokir rute dashboard admin, jadi tidak ada lapisan baru yang perlu diperkenalkan, cuma satu lapisan lama yang berhenti ikut campur.
Jebakan kedua: browser mengingat 301
Selama iterasi aturan /posts/{slug} itu, saya terus melihat target redirect lama di browser meski logika servernya sudah berubah. Tampak seperti kode baru belum ter-deploy, padahal sudah. Browsernya saja yang tidak pernah bertanya lagi ke server. Begitu browser sudah menyelesaikan URL_A ke URL_B sebagai 301, dia tidak menanyakan server lagi pada kunjungan berikutnya ke URL_A, dan hard refresh pun sering tidak menembusnya. Ini menyandung berkali-kali di sesi itu: redirect /posts/{slug} ke /stories/{slug} yang lama tetap ter-cache setelah middleware diubah.
Tiga cara verifikasi yang saya pegang sejak itu: jendela incognito, DevTools Network dengan Disable cache dicentang, atau bersihkan cache browser untuk host itu. Hard refresh Ctrl+Shift+R tidak andal untuk 301 yang ter-cache.
salah Ctrl+Shift+R di tab yang sudah pernah kena 301 browser menjawab dari cache-nya sendiri
benar jendela incognito tidak ada 301 tersimpan
benar Network -> Disable cache, DevTools tetap terbuka
benar hapus cache browser untuk host ituAda satu implikasi yang saya catat sebagai pertimbangan, bukan sesuatu yang dilakukan di migrasi ini: pengguna nyata dengan cache 301 basi bisa melihat perilaku lama selama berhari-hari, dan kalau target redirect berubah signifikan, 302 sementara untuk jendela singkat sebelum kembali ke 301 bisa dipertimbangkan.
Pelajaran
Kalau tabel redirect di middleware sudah benar tapi hasilnya tetap salah, cari aturan yang berdiri di depannya. redirects() di next.config.ts menyala sebelum middleware, jadi satu pola blanket di sana cukup untuk menutupi seluruh lookup selektif tanpa satu pun error. Kalau ada prefiks URL yang butuh keputusan per path, satu lapisan saja yang boleh memegangnya. Dan sebelum menyimpulkan perbaikannya belum ter-deploy, buka incognito dulu.