D
P
0
← Semua artikel Read in English

Next.js & React di Produksi

Setiap Halaman `no-store` dan `x-vercel-cache: MISS`? Header yang Membaca Sesi Memaksa Next.js Merender per Request

· · 10 menit baca
Setiap Halaman `no-store` dan `x-vercel-cache: MISS`? Header yang Membaca Sesi Memaksa Next.js Merender per Request

Laporan Lighthouse mobile untuk staging pada 15 September 2026 mencatat 67 untuk home, 70 untuk about, dan 74 untuk events. LCP-nya di atas enam detik, dan penyebab yang ditunjuk adalah document request latency, yaitu waktu yang habis sebelum dokumen HTML-nya sendiri sampai di browser. Saya mengaitkannya dengan satu hal: setiap halaman publik dirender per request, di Vercel Hobby dengan database Neon.

Sebelum membangun perbaikan apa pun, saya mengukur staging dulu. TTFB dingin 4,7 detik, TTFB hangat 0,75 sampai 1,1 detik, dan setiap halaman membawa pasangan header yang sama: no-store dan x-vercel-cache: MISS.

Dua header itu sudah menunjukkan arah perbaikannya. no-store melarang cache mana pun menyimpan respons, dan MISS berarti edge Vercel tidak punya salinan untuk disajikan, jadi setiap kunjungan menunggu fungsi server merender halaman dari awal. Dari pengukuran itu saya memutuskan bahwa perbaikannya harus berupa halaman yang disajikan dari edge, bukan query yang di-cache.

Biayanya ada di dokumen, bukan di query

Situsnya Next.js App Router dengan Payload sebagai CMS, multi-tenant, tiga bahasa, dan punya area member. Kalau yang lambat adalah query, menyimpan hasilnya di cache memang jawabannya. Di sini yang lambat adalah dokumennya. Pesan commit perbaikannya merangkum masalah itu: biaya terbesar ada di dokumen itu sendiri, karena setiap halaman membaca request (host untuk tenant, header untuk bahasa, cookie untuk member), sehingga dirender per request dengan cold start beberapa detik di Vercel.

Di App Router, headers() dan cookies() termasuk API dinamis. Server component yang memanggil salah satunya saat render membuat route-nya tidak bisa dibuat sekali saat build, karena hasilnya bergantung pada request yang belum ada. Route itu dirender ulang untuk setiap kunjungan dan dikirim dengan no-store. Pemanggilannya juga tidak harus ada di page.tsx. Satu komponen yang muncul di setiap halaman, misalnya header, sudah cukup untuk menyeret semua halaman ke render per request. Query yang di-cache tidak mengubah itu, karena render-nya tetap terjadi untuk setiap pengunjung.

Siapa saja yang membaca request

Daftarnya bisa dibaca langsung dari kode sebelum perbaikan. Header memanggil fungsi pembaca sesi di server supaya member yang sudah sign in disapa dengan namanya di setiap halaman, dan untuk member itu tombol undangan serta sign in dikosongkan. Footer memanggil fungsi yang sama untuk mengganti ajakan di bawah tagline menjadi tautan ke area member. Fungsi pembaca sesi itu membaca cookie di setiap request: pertama sesi dari penyedia login hosted, lalu sesi bawaan Payload lewat payload.auth dengan header request.

Itu baru urusan member. Fungsi pencari tenant membaca header host di setiap pemanggilan untuk mencocokkannya dengan domain tenant. Bahasa diambil dari header request yang ditulis middleware. Tautan pindah bahasa di header, menu ponsel, dan footer dibangun dari header lain yang berisi path. Blok daftar koleksi ikut memanggil pembaca sesi, lalu meneruskan member dan header request ke query Payload. Sitemap pun tetap dinamis, karena fungsi pengambil setelan global situs membaca request.

// Sebelum perbaikan. Disederhanakan, nama fungsi dan header diganti.
 
// Header dan Footer, di setiap halaman
const member = await getMember() // cookies(), lalu headers()
 
// pencari tenant, di setiap pemanggilan
const host = (await headers()).get('host')?.split(':')[0] ?? ''
 
// bahasa, dari header yang ditulis middleware
const lang = (await headers()).get('x-lang')
 
// tautan pindah bahasa
const path = (await headers()).get('x-path') || '/'

Masing-masing baris itu cukup untuk membuat halaman dinamis sendirian. Artinya tidak ada satu baris yang bisa dicabut lalu masalahnya selesai. Selama satu pembaca masih tersisa di jalur render halaman publik, halaman itu tetap dirender per request.

Bahasa pindah ke URL, halaman disimpan sejam

Semua route frontend pindah ke bawah segmen [lang]. Middleware me-rewrite /events ke /en/events, membiarkan /fr/events apa adanya, mengirim /en/... ke alamat polosnya dengan 308 supaya setiap halaman hanya punya satu alamat, dan melewatkan apa pun yang berekstensi berkas tanpa menyentuhnya. Bahasa lalu dibaca dari segmen itu lewat next/root-params di server component. Header bahasa hanya dipakai sebagai cadangan di server action atau route handler, sehingga tidak ada lagi halaman yang membaca request untuk tahu bahasanya.

Dengan bahasa sebagai bagian dari route, halaman bisa dirender tanpa menunggu request. Layout [lang] memasang revalidate = 3600 dan generateStaticParams untuk tiga bahasa. Halaman katalog memprerender setiap halaman terbit dalam setiap bahasa saat build, sementara alamat lain dirender pada kunjungan pertamanya. Sitemap membaca daftar bahasa yang ditawarkan tanpa bahasa halaman, dan disimpan satu jam.

Tenant mendapat jawaban yang lebih sederhana. Fungsi pencari tenant hanya membaca host kalau sebuah variabel env bernilai 1, dan .env.example menuliskan harganya terang-terangan.

// Sesudah perbaikan. Disederhanakan, nama diganti.
 
// app/[lang]/layout.tsx
export const revalidate = 3600
 
export function generateStaticParams() {
  return LANGS.map((lang) => ({ lang }))
}
 
// pencari tenant
const host = process.env.MULTI_DOMAIN === '1'
  ? ((await headers()).get('host')?.split(':')[0] ?? '')
  : ''
 
// .env.example:
// Set to 1 only when more than one tenant answers on its own domain: the host
// is then read on every request and no public page is kept.

Tautan pindah bahasa tidak bisa lagi dibangun dari header path. Sekarang tautan itu dibangun di browser dari address bar oleh sebuah komponen klien, karena header dan footer dirender sekali untuk semua halaman, jadi keduanya tidak tahu sedang berada di halaman mana.

Halaman yang disimpan juga butuh jalan keluar saat konten berubah. Hook afterChange dan afterDelete di setiap koleksi konten dan di kedua global memanggil revalidatePath('/', 'layout'). Di luar request, misalnya di seed atau skrip, Next menolak panggilan itu, dan kodenya hanya mencatatnya sekali lalu menelannya.

// Disederhanakan. Dipasang sebagai afterChange dan afterDelete
// di setiap koleksi konten dan di kedua global.
try {
  revalidatePath('/', 'layout')
} catch (error) {
  // Di luar request (seed, skrip) Next menolak panggilan ini.
  // Dicatat sekali, sesudahnya ditelan.
}

Nama member pindah ke browser

Bagian yang paling banyak berubah adalah sapaan member. Header dan footer tidak lagi membaca sesi sama sekali. Gantinya sebuah context provider di klien. Pengunjung tanpa cookie penanda dianggap anonim tanpa biaya apa pun, dan hanya pengunjung yang membawa penanda itu yang ditanyakan ke route handler baru, /whoami. Route itu membaca sesi dari cookie di setiap panggilan, tidak pernah disimpan cache mana pun, dan menjawab nama member atau {} dengan Cache-Control: private, no-store, max-age=0.

Penandanya cookie terpisah bernilai 1 yang dipasang di samping cookie sesi dengan httpOnly: false. Menurut komentarnya, cookie itu tidak membawa rahasia apa pun, hanya fakta bahwa sesi ada, dan ikut dihapus ketika sesi berakhir. httpOnly: false di sini disengaja, karena cookie ber-httpOnly tidak terlihat oleh document.cookie, sedangkan penanda ini memang ada untuk dibaca skrip.

// Disederhanakan, nama diganti.
 
// dipasang di samping cookie sesi, dihapus bersama sesi
jar.set('signed-in', '1', { httpOnly: false, sameSite: 'lax', secure, path: '/', expires })
 
// app/[lang]/whoami/route.ts
export async function GET() {
  const member = await getMember()
  const body = member ? { name: displayName(member) } : {}
  return NextResponse.json(body, {
    headers: { 'Cache-Control': 'private, no-store, max-age=0' },
  })
}

Di browser ada dua langkah. Skrip inline di layout menandai elemen root dengan sebuah kelas sebelum paint pertama kalau cookie penanda ada, dan CSS menyembunyikan aksi untuk tamu di bawah kelas itu, jadi tombol undangan dan sign in tidak sempat berkedip di depan member. Sesudah itu provider klien bertanya ke /whoami untuk mendapatkan namanya.

// Disederhanakan, nama diganti.
 
// skrip inline di layout, jalan sebelum paint pertama
if (/(^|; )signed-in=1(;|$)/.test(document.cookie))
  document.documentElement.classList.add('is-signed-in')
 
// CSS: .is-signed-in .guest-only { display: none; }
 
// provider klien, hanya untuk pengunjung yang membawa penanda
fetch('/whoami', {
  credentials: 'same-origin',
  cache: 'no-store',
  headers: { accept: 'application/json' },
})

Ada harga kecil yang dibayar member. Blok daftar koleksi sekarang membaca sebagai pengunjung, jadi halaman hanya menampilkan apa yang boleh dilihat siapa pun. Item bergembok tetap memakai teaser dan dibuka dari halamannya sendiri, sehingga member hanya kehilangan tanda "unlocked" di dalam blok daftar. Halaman yang memang membaca sesi sengaja tetap dirender per request: area member, direktori, join, sign in, halaman undangan, serta halaman detail event dan konten.

Bukti di build produksi dan di staging

Di next start, TTFB untuk /, /about, /events, /fr, dan /es/events turun dari 0,5 sampai 1,0 detik menjadi di bawah 10 ms, dengan x-nextjs-cache: HIT dan s-maxage=3600. Daftar route hasil build menunjukkan 39 halaman katalog dan sitemap sebagai prerendered serta route member sebagai dinamis, dengan tsc dan 96 tes hijau.

Personalisasinya diuji dari dua sisi. Member yang sign in lewat form bawaan melihat sapaan dengan namanya, sementara browser baru mendapat halaman yang di-cache tanpa nama itu, dan /whoami menjawab {}. Invalidasi juga diuji: judul SEO yang diubah lewat REST API membuat /about, /fr/about, dan /es/about menjadi MISS dengan judul baru, lalu kembali sesudah judulnya dipulihkan.

Percobaan invalidasi pertama sebenarnya tampak gagal. Ternyata server di-restart di antara edit dan pengecekan, sementara next start menyimpan tanda revalidasinya di memori, jadi tanda itu hilang bersama prosesnya. Di Vercel tanda itu bertahan. Kalau kamu menguji revalidasi di lokal, jangan me-restart server di tengah percobaan.

Sesudah perbaikan ter-deploy, setiap halaman publik di staging menjawab x-vercel-cache: HIT, atau PRERENDER pada request pertama sesudah deploy. Bagian server dari TTFB sekitar 100 ms dari Bali. Pemeriksaan yang sama mencatat /en/about menjawab 308, /whoami menjawab {}, pengalihan ke login hosted, callback, dan 404 tetap seperti sebelumnya, dan tidak ada error console di browser pada lebar 1440 maupun 390. Perjalanan login lengkap dengan akun member uji juga dijalani: login hosted, callback, area member, lalu sapaan bernama di home yang di-cache dengan aksi anonim tersembunyi, kemudian logout.

                         sebelum                    sesudah
next start, TTFB         0,5 sampai 1,0 detik       di bawah 10 ms, x-nextjs-cache: HIT
staging, TTFB hangat     0,75 sampai 1,1 detik      sekitar 100 ms (bagian server, dari Bali)
staging, TTFB dingin     4,7 detik
x-vercel-cache           MISS                       HIT (PRERENDER di request pertama
                                                    sesudah deploy)

Lighthouse tidak ikut bergerak

Laporan Lighthouse adalah alasan awal pekerjaan ini, dan waktu itu saya mengira cache pass akan menjawabnya. Ternyata tidak. Lighthouse mobile 13.4.1 dengan simulated slow 4G mencatat home 59 lalu 68, about 71, dan events 67, dibanding baseline 67, 70, dan 74. TTFB yang turun dari hitungan detik ke sekitar seratus milidetik tidak terlihat di skor itu. Penjelasan yang saya catat: simulated throttling hampir tidak menghargai waktu server, dan bobot halaman sendirilah yang menentukan skor.

Bobotnya memang besar. Elemen LCP di home adalah sebuah foto, dan audit itu memuat seluruh 96 frame sekuens gambar di hero, 3 MB dari halaman 4 MB, karena timer enam detik melepas sisa sekuens selama audit berjalan. Tiga font yang di-preload menambah 329 KB. Commit berikutnya membuat 24 frame pembuka menunggu event load dan sisanya menunggu scroll pertama. Dicek di lebar 390, hasilnya 24 frame sesudah load dan 96 sesudah scroll. Font belum disentuh di commit itu.

Sesudah deploy itu, home di staging mencetak 69 lalu 77, dengan FCP 1,7 detik, LCP 6,1 detik, 24 frame, dan halaman 1,8 MB. Pembandingnya 59 dan 68 sebelum commit itu, dan 67 di baseline, dengan variasi antar-run sekitar delapan poin. Halamannya turun dari 4 MB ke 1,8 MB, tapi LCP-nya masih di atas enam detik.

Kapan halaman tersimpan benar-benar berubah

Sesudah perbaikan, beberapa catatan memperlihatkan kapan halaman tersimpan tidak langsung ikut berubah. Edit langsung di database tidak memicu hook revalidasi, jadi pada 15 September yang menyegarkan halaman tersimpan adalah push yang membawa perubahan seed. Pada 29 September saya mencatat bahwa request pertama sesudah seed masih menyajikan halaman yang di-cache (stale-while-revalidate), jadi halaman harus dihangatkan dulu sebelum dicek. Pada 1 Oktober, terjemahan FR dan ES hasil apply sudah ada di database, tapi halaman staging tetap menyajikan cache build sampai saya menekan Save di admin.

Jadi kalau isi database sudah benar tapi halamannya belum, periksa dulu lewat jalur mana perubahan itu masuk, dan hangatkan halamannya sebelum menilai.

Satu syarat untuk halaman publik

Dokumentasi arsitektur yang saya tulis sesudah perbaikan ini merumuskan syaratnya dalam satu kalimat: agar halaman tetap tersimpan, tidak ada bagian halaman publik yang boleh membaca request. Syarat itu yang membedakan kasus ini dari masalah cache data biasa. Yang harus dijaga bukan setelan cache sebuah fetch, melainkan setiap komponen di jalur render halaman publik, termasuk header dan footer yang muncul di mana-mana.

Kalau setiap halaman di proyek Next kamu membawa no-store dan x-vercel-cache: MISS, mulailah dari daftar route hasil next build, yang menandai route mana yang prerendered dan mana yang dinamis. Lalu cari pemanggilan headers() dan cookies() di komponen yang dipakai setiap halaman, bukan hanya di page.tsx. Bagian yang memang milik satu pengunjung, seperti nama member, bisa diminta browser dari route handler yang privat dan no-store, dengan cookie penanda yang bisa dibaca skrip supaya pengunjung anonim tidak perlu bertanya sama sekali. Sesudah TTFB beres, jangan berharap skor Lighthouse simulasi ikut naik. Menurut penjelasan yang saya catat, yang menentukan skor itu adalah bobot halaman sendiri.