D
P
0
← Semua artikel Read in English

Next.js & React di Produksi

Header Middleware Astro Muncul di Dev tapi Tidak Pernah Sampai ke Halaman Prerender di Vercel

· · 9 menit baca
Header Middleware Astro Muncul di Dev tapi Tidak Pernah Sampai ke Halaman Prerender di Vercel

Satu respons dari dev server Astro sudah memperlihatkan ada yang janggal. Halaman prerender / membalas 200 dengan keempat header keamanan yang dipasang middleware, tapi cookie asal pengunjung yang ditulis middleware yang sama tidak ikut, padahal permintaannya membawa Referer dari sebuah jejaring profesional yang semestinya memicu klasifikasi. Jumlah Set-Cookie-nya nol. Header yang tampak beres di dev itu pun tidak ikut tersimpan di keluaran build untuk Vercel.

Situs portofolio saya sebelumnya berjalan di Next, dan di sana kedua hal ini bekerja tanpa pernah saya pikirkan. Empat header keamanan, yaitu X-Content-Type-Options, X-Frame-Options, Referrer-Policy, dan Permissions-Policy, didefinisikan sebagai securityHeaders di next.config.ts lalu dipasang lewat headers() dengan source: "/:path*". Rencana migrasi saya mencatat bahwa bentuk itu memasang keempatnya pada semua respons, termasuk berkas di public/. Cookie-nya bernama vsrc, ditulis src/proxy.ts dengan umur 30 hari, sameSite: "lax", dan path: "/". Komponen Hero membacanya untuk menggeser penekanan sedikit: perekrut yang datang dari jejaring profesional melihat kontak dan CV lebih dulu, sedangkan developer yang datang dari platform kode melihat sudut cerita debugging.

Di produksi Next, perilaku itu bisa diamati langsung. Permintaan dengan Referer dari jejaring profesional tadi dibalas set-cookie untuk vsrc dengan Max-Age=2592000 dan SameSite=lax, dan 2.592.000 detik itu 60 × 60 × 24 × 30, persis 30 hari. Cookie itu tetap diset walaupun respons / adalah cache HIT, karena middleware Vercel berjalan sebelum cache.

Situs penggantinya Astro dengan output: 'static'. Rencana migrasi bertanggal 6 September 2026 sudah mencatat bahwa cookie vsrc tidak bisa datang dari middleware pada halaman prerender, dan menyerahkan penyelesaiannya ke sebuah spike sebelum apa pun dibangun. Spike itu berjalan 8 September 2026 dengan Astro 7.3.1, @astrojs/vercel 11.0.10, dan Node 22.23.2 di macOS, dan angka-angkanya keluar dari perintah yang benar-benar dijalankan, bukan dari dokumentasi. Di spike itu, keempat header keamanan dan cookie vsrc sama-sama diserahkan ke middleware Astro.

Uji di dev server memakai halaman prerender /, middleware tanpa guard isPrerendered (properti konteks Astro yang menandai rute prerender), dan Referer jejaring profesional tadi:

halaman /            200, jumlah Set-Cookie: 0
                     tapi empat header keamanan MUNCUL
aset /favicon.svg    200, nol header keamanan
rute on-demand       200, header keamanan muncul

Di dev, middleware memang berjalan untuk halaman prerender. Cookie-nya dibuang, sedangkan header-nya tetap terpasang. Aset di public/ tidak pernah melewati middleware sama sekali.

Build menyebut sebabnya terang-terangan. Peringatan berikut muncul tiga kali, satu untuk tiap halaman prerender:

[WARN] `Astro.request.headers` was used when rendering the route `src/pages/index.astro`.
       `Astro.request.headers` is not available on prerendered pages.

Middleware untuk halaman prerender dijalankan saat build, dengan permintaan palsu yang tidak membawa header asli. Saat itu belum ada pengunjung, jadi cookie per pengunjung secara struktural tidak mungkin lahir dari sana. Header keamanan lain nasibnya, karena nilainya sama untuk semua orang. Header semacam itu bisa saja dihitung sekali saat build, asalkan ada yang menyimpannya.

staticHeaders hanya membawa CSP

Rencana awal saya menyerahkan penyimpanan itu ke opsi staticHeaders milik adapter Vercel. Kalimatnya di rencana menyebut opsi itulah yang menulis header dari middleware untuk rute prerender ke konfigurasi Vercel. Catatan spike menyebut anggapan itu keliru, dan keliru dengan cara yang penting.

Yang terukur dengan staticHeaders: true dan middleware yang menyetel keempat header keamanan: .vercel/output/config.json berisi 9 route, dan hanya 2 yang berheader, yaitu redirect 308 untuk path yang berakhir garis miring dan cache-control untuk /_astro/. Nol header keamanan tersimpan. vercel.json pun tidak disentuh: sha256-nya sama sebelum dan sesudah build.

Sebabnya ada di sumber adapter. Ini lima baris pertama fungsi yang membuat rute header dari staticHeaders di @astrojs/vercel 11.0.10:

// node_modules/@astrojs/vercel/dist/index.js
function createRoutesWithStaticHeaders(staticHeaders, config) {
  const vercelHeaders = [];
  for (const [pathname, { headers }] of staticHeaders.entries()) {
    if (config.security.csp) {
      const csp = headers.get("Content-Security-Policy");

Fungsi itu hanya mengambil Content-Security-Policy, dan hanya di dalam syarat config.security.csp. Header lain tidak ikut. Posisinya sendiri tidak bermasalah, karena kalau staticHeaders menyala, adapter menyisipkan rute hasil fungsi itu tepat sebelum handle: filesystem. Hanya saja yang sampai ke posisi itu cuma CSP.

Dokumentasi Astro sebenarnya konsisten dengan perilaku ini lewat frasa "when provided by Astro features, such as Content Security Policy". Masalahnya, frasa itu terbaca seperti contoh, padahal itu satu-satunya kasus.

Ada keputusan lain di rencana yang ikut gugur karena berdiri di atas premis yang sama. Rencana sempat memutuskan untuk tidak memasang guard isPrerendered dengan alasan staticHeaders menyimpan header-nya. Karena tidak ada yang disimpan, guard itu sekarang tidak berpengaruh pada header, jadi keputusannya bebas. Yang tetap benar, memasangnya tidak akan memperbaiki apa pun.

Mode edge baru tercapai sesudah handle: filesystem

Untuk cookie, rencana menyiapkan dua jalur. Jalur A memakai middlewareMode: 'edge' dan membuang guard isPrerendered dari visitorSource, dengan anggapan middleware edge akan menjangkau halaman statis sehingga paritas cookie tercapai penuh. Jalur B memindahkan klasifikasi ke skrip klien. Rekomendasi rencana sebelum spike adalah membangun Jalur B sebagai bentuk yang pasti bekerja, lalu naik ke Jalur A hanya kalau spike membuktikannya.

Dengan middlewareMode: 'edge', adapter memang membuat _middleware.func terpisah. Yang menentukan adalah urutan rute yang ditulisnya:

1  ^/(.*)/$              redirect 308
2  handle: filesystem                <- berkas statis dilayani DI SINI
3  ^/_astro/(.*)$         cache-control
4  ^/_server-islands/...  -> _middleware
5  ^/_image$              -> _middleware
6  ^/api/...              -> _middleware
7  ^/404/?$               -> _middleware
8  ^/.*$                  -> _middleware, status 404

handle: filesystem duduk di posisi kedua, dan semua rute yang menuju _middleware duduk sesudahnya. Halaman statis dan aset public/ sudah selesai dilayani sebelum baris-baris itu tercapai, jadi Jalur A mati.

Produksi kemudian mengonfirmasinya. curl ke halaman prerender dengan Referer yang sama menghasilkan nol Set-Cookie. Jalur B tidak lagi sekadar kesimpulan dari membaca urutan rute, melainkan hasil pengukuran.

Header pindah ke vercel.json

Opsi staticHeaders dibuang, dan seluruh beban header dipindah ke vercel.json. Satu hal belum pasti saat itu, yaitu apakah headers di vercel.json akar berlaku untuk deployment yang dibangun lewat Build Output API. Dokumentasi Build Output Configuration tidak menyebut hubungan keduanya, halaman Project Configuration juga tidak, dan adapter membaca vercel.json hanya untuk memperingatkan konflik trailingSlash, bukan untuk menggabungkan header. Pertanyaan itu hanya bisa dijawab deployment sungguhan.

Blok yang dipasang memakai nilai yang sama persis dengan securityHeaders di Next, dengan source yang mencakup semua path. Di sini spasinya saya padatkan:

{
  "source": "/(.*)",
  "headers": [
    { "key": "X-Content-Type-Options", "value": "nosniff" },
    { "key": "X-Frame-Options", "value": "SAMEORIGIN" },
    { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
    {
      "key": "Permissions-Policy",
      "value": "camera=(), microphone=(), geolocation=(), browsing-topics=()"
    }
  ]
}

Spike ditutup hari itu juga, dan butir yang tersisa dijawab dari deployment sungguhan. Di deployment produksi projek Astro, /, /og-image.png, sebuah berkas fonta .woff2 di /fonts/, dan /favicon.svg semuanya membalas 200 dengan keempat header keamanan. Jadi vercel.json dihormati walaupun deployment dibangun lewat Build Output API, untuk halaman prerender maupun aset png, woff2, dan svg, dan tidak perlu integrasi Astro tambahan yang menyuntik rute di astro:build:done.

Uji itu baru mencakup empat path pada projek yang masih kosong, dan rencana meminta curl -sI yang sama diulang nanti pada daftar path lengkap. Saat cangkang situs selesai di hari yang sama, deployment sungguhan kembali memperlihatkan keempat header pada halaman dan aset. Di astro.config.mjs, keputusan ini sekarang tertulis sebagai komentar bahwa staticHeaders dan middlewareMode: 'edge' sengaja tidak dipakai, lengkap dengan alasan masing-masing.

Beban yang pindah ke vercel.json ternyata bukan cuma header keamanan. Audit sesudah domain dipindah ke Astro menemukan seluruh /_astro/ dan /fonts/ dilayani public, max-age=0, must-revalidate, sehingga kunjungan ulang membayar sampai 59 permintaan bersyarat. Sekarang vercel.json memasang public, max-age=31536000, immutable untuk /_astro/(.*) dan /fonts/(.*). Angka itu 60 × 60 × 24 × 365 detik, alias setahun, dan tambalannya diverifikasi ke situs hidup, bukan ke hasil build.

Untuk cookie, jawabannya Jalur B, skrip klien. Logika classify() disalin karakter demi karakter dari versi server: utm_source tetap menang atas referer, empat pola sumber yang dikenal tetap sama, awalan www. tetap dibuang, dan host yang mengandung domain situs sendiri atau persis localhost tetap diabaikan. Yang berubah hanya sumbernya, dari header permintaan menjadi document.referrer dan location.search. Penulisan cookie-nya ada di src/scripts/vsrc.ts:

export function tandaiAsalPengunjung(): void {
  if (/(?:^|;\s*)vsrc=/.test(document.cookie)) return;
  const src = classify(document.referrer, new URLSearchParams(location.search));
  if (!src) return;
  document.cookie = `vsrc=${src}; Path=/; Max-Age=2592000; SameSite=Lax`;
}

Cookie hanya ditulis kalau belum ada, dengan atribut yang sama seperti di Next: seluruh path, 30 hari, SameSite lax. Klasifikasi versi klien ini diuji lawan versi Next pada 19 kasus, termasuk tempelan sufiks dan utm yang menang atas referer, dan hasilnya sama semua.

Ada konsekuensi yang sudah saya setujui: pengunjung tanpa JavaScript tidak pernah mendapat cookie ini. Satu-satunya pembacanya adalah Hero, yang juga butuh JavaScript, jadi tidak ada perilaku yang hilang. Hero mengambil cookie itu dari document.cookie di dalam useEffect, dan komentar di atasnya masih menyebut sumbernya "set by middleware", padahal di Astro yang menulisnya skrip klien. Fungsi penulisnya diimpor di blok <script> layout cangkang, Shell.astro, lalu dipanggil sesudah muatBahasaTersimpan() dan pasangPenukarBahasa(), sebelum pasangGulirHalus().

Bagian cookie ini belum terbukti ujung ke ujung. Konsekuensi Jalur B yang tercatat adalah satu butir ceklis paritas harus ditulis ulang menjadi pemeriksaan Playwright terhadap document.cookie, dan saya tidak menemukan catatan bahwa pemeriksaan itu sudah dijalankan. Yang terukur di produksi sejauh ini adalah masalahnya, yaitu nol Set-Cookie untuk halaman prerender, sedangkan perbaikannya baru terbukti di tingkat logika lewat 19 kasus tadi.

Di rute on-demand, middleware malah menimpa halaman

Middleware tidak hilang dari situs Astro. Ia tinggal memasang header untuk rute on-demand, dan per 8 September 2026 komentar di src/middleware.ts menyatakan berkas itu menduplikasi apa yang sudah dipasang vercel.json, khusus untuk 15 rute on-demand yang tidak lewat handle: filesystem.

Di rute-rute itu, masalahnya justru kebalikannya. Untuk satu rute privat, middleware sempat menyetel Cache-Control tanpa syarat, dan karena ia berjalan sesudah await next(), nilainya menang atas nilai yang disetel halaman. Terukur di deployment, produksi Next mengirim nilai halaman dinamis lima bagian, private, no-cache, no-store, max-age=0, must-revalidate, sedangkan Astro mengirim nilai dua bagian dari middleware. Baris itu ditulis saat cangkang situs dibangun, ketika rute privat tersebut belum ada, dan cacatnya sekelas dengan kunci konfigurasi yang tertulis dua kali: yang belakangan menang diam-diam.

Perbaikannya mengubah aturan, bukan cuma nilai. Middleware sekarang hanya mengisi Cache-Control di cabang itu kalau respons belum membawanya:

if (!response.headers.has('Cache-Control')) {
  response.headers.set('Cache-Control', 'no-store, must-revalidate');
}

Ada satu pertanyaan yang masih terbuka di rute itu, karena vercel.json juga menyetel Cache-Control untuknya. Satu commit sempat menyimpulkan bahwa header di vercel.json menimpa header yang disetel halaman. Commit yang masuk belakangan membantah dasar kesimpulan itu: nilai di vercel.json dinaikkan ketika middleware masih menimpa header halaman, jadi tidak ada cara membedakan siapa yang menang. Sesudah middleware diperbaiki, semestinya halaman yang menang, tapi commit yang sama menulis bahwa pemenangnya baru akan diukur di deployment, dan hasil ukur itu tidak ada di catatan saya.

Yang menjawab duluan adalah berkas statis

Dirangkum dari yang terukur, di deployment Vercel, middleware Astro menyentuh halaman prerender hanya saat build, ketika belum ada pengunjung. staticHeaders di adapter 11.0.10 hanya membawa CSP dari sana. Rute middleware edge baru tercapai sesudah handle: filesystem selesai melayani berkas statis. Di Next, middleware Vercel berjalan sebelum cache sehingga cookie tetap terpasang pada respons HIT, sedangkan di Astro statis, berkas statis dilayani lebih dulu. Yang mendahului middleware di sini bukan konfigurasi di dalam framework, melainkan tahap routing Vercel yang memeriksa berkas statis.

Header yang nilainya sama untuk semua pengunjung punya tempat yang terbukti bekerja untuk halaman prerender dan aset, yaitu vercel.json. Keadaan per pengunjung di halaman statis harus lahir di peramban, lengkap dengan konsekuensinya bagi pengunjung tanpa JavaScript. Sebelum memindahkan middleware dari Next ke Astro statis di Vercel, buka .vercel/output/config.json hasil build dan cari posisi handle: filesystem. Rute yang tercantum sesudahnya tidak akan melihat halaman prerender maupun aset public/, padahal dev server tetap memasang header middleware di halaman prerender seolah semuanya beres.