Selama semua halaman situs ini dirender saat build, ada satu jalur yang tidak pernah tersentuh: GSAP yang dimuat di dalam fungsi server Vercel. Jalur itu baru terpakai ketika halaman on-demand pertama muncul, dan halaman itu membalas 500, sementara build-nya hijau dan 506 halaman statisnya sehat.
Halaman itu dasbor pengunjung privat, dan kejadiannya 8 September 2026, di tengah pembangunan ulang situs ini dari Next.js ke Astro. Arsitektur yang diputuskan untuk situs barunya adalah output: 'static' dengan adapter Vercel, dan prerender = false hanya pada 20 rute. Dependensinya terpin sejak spike awal di hari yang sama, yaitu astro 7.3.1 dan @astrojs/vercel 11.0.10, sementara GSAP dipin persis di 3.14.2.
Tulisan saya soal Can't resolve '@sanity/ui' bercerita tentang build gagal yang tertutup oleh versi lama yang masih dilayani. Kasus ini arahnya terbalik. Build-nya memang berhasil, deploy yang membawa dasbor itu memang mendarat, dan kegagalannya baru muncul saat satu fungsi server dipanggil. Akarnya pun bukan dependensi yang lupa dideklarasikan, melainkan bentuk package.json GSAP yang dibaca berbeda oleh runtime fungsi Vercel.
Satu halaman on-demand di antara 506 halaman statis
Pembangunan ulangnya dibagi ke tahap bernomor. Sampai tahap M5, semua halaman dirender saat build. Commit tahap M6 menambahkan pelacakan pengunjung, empat endpoint, dan dasbor privat, dan sesudahnya keluaran build menjadi hibrida: 506 halaman tetap statis dan dilayani dari filesystem, sementara hanya lima rute yang mencapai fungsi server.
Empat endpoint ditambah satu halaman, jumlahnya pas lima. Keempat endpoint itu wajib memasang prerender = false, karena endpoint yang tidak opt out akan dieksekusi saat build lalu dibekukan jadi berkas statis. Halamannya hanya satu, yaitu dasbor, yang juga wajib on-demand karena membaca cookie per request. Kalau dibangun statis, halaman itu akan membekukan satu cabang render untuk semua orang.
Pada tahap itu domain utama masih melayani situs Next dan baru dipindah ke Astro belakangan pada hari yang sama, jadi 500 ini muncul di deployment produksi projek Astro di Vercel. Projek itu punya alias publiknya sendiri, yang sejak deploy pertama membawa X-Robots-Tag: noindex, nofollow.
Dari dasbor ke import gsap
Jalan GSAP ke fungsi server melewati cangkang situs. Halaman dasbor mengimpor cangkang itu dan merender isinya di dalam cangkang tersebut. Cangkang merender sebuah komponen pembuka dengan direktif client:load, dan komponen itu mengimpor gsap. Kalau disederhanakan, rantainya begini:
halaman dasbor prerender = false, dirender per request
-> Layout.astro cangkang yang juga dirender halaman lain
-> <Intro client:load />
-> import gsap from "gsap"
disederhanakan; Layout dan Intro adalah nama netral, bukan nama berkas aslinyaKomponen ber-client:load tetap ikut dirender di server, dan soal itu punya tulisannya sendiri. Di sini cukup satu akibatnya: modul komponen pembuka, berikut import gsap di dalamnya, ikut dievaluasi di mana pun cangkang itu dirender.
Untuk halaman statis, evaluasi itu terjadi saat build, dan Node saat build memuat ESM tanpa masalah. Dasbor adalah halaman on-demand pertama yang ikut merender cangkang, jadi dialah yang pertama menyeret GSAP ke runtime server.
Paket tanpa type yang menunjuk berkas ESM
Penyebabnya saya rangkum di komentar konfigurasi yang masuk bersama perbaikannya. Paketnya menyatakan diri commonjs tapi peta exports-nya menunjuk index.js yang isinya ESM, jadi Node di fungsi serverless memuatnya sebagai CommonJS dan langsung melempar "Cannot use import statement outside a module". Pesan galat itu saya kutip dari komentar tersebut, karena log fungsi Vercel-nya sendiri tidak tersimpan di catatan saya.
Bentuk paketnya bisa diperiksa langsung. Berikut medan yang relevan dari package.json GSAP 3.14.2 yang terpasang, dipotong dari berkas aslinya:
{
"version": "3.14.2",
"module": "index.js",
"main": "dist/gsap.js",
"exports": {
".": {
"import": "./index.js",
"require": "./dist/gsap.js",
"types": "./types/index.d.ts",
"default": "./index.js"
}
}
}Yang tidak ada di potongan itu sama pentingnya, yaitu medan type di tingkat atas. Satu-satunya kunci "type" di berkas itu berada di dalam blok repository dan bernilai git. Jadi frasa "menyatakan diri commonjs" di komentar saya lebih tepat dibaca sebagai "tidak menyatakan diri ESM". Sementara itu, index.js dibuka dengan pernyataan import ESM dari ./gsap-core.js.
Kondisi import di peta exports hanya memilih berkas mana yang dipakai, bukan cara loader membaca berkas itu. Medan module malah tidak dibaca Node sama sekali. Medan itu konvensi untuk bundler, dan ini cocok dengan kalimat lain di komentar yang sama: GSAP mengandalkan bundler, dan di sini bundlernya harus disuruh eksplisit.
Yang membuat kasus ini licin, mesin lokal tidak mereproduksinya. Diuji langsung di sana, import() maupun require() gsap dua-duanya berhasil. Komentar di gerbang build yang saya pasang sesudah kejadian ini menjelaskannya dengan resolusi kondisi yang berbeda antara mesin lokal dan launcher Vercel, dan catatan saya tidak memuat penjelasan yang lebih rinci dari itu.
ssr.noExternal, dan kunci vite yang ditulis dua kali
Secara bawaan, build SSR Vite membiarkan dependensi dari node_modules sebagai external. Keluaran server tetap berisi import ke nama paketnya, dan paket itu baru dimuat saat fungsi berjalan. Obatnya vite.ssr.noExternal: ['gsap'], yang memaksa GSAP ikut dibundel. Kode GSAP lalu disalin bundler ke chunk server, sehingga runtime fungsi tidak perlu lagi memuatnya dari node_modules.
Perbaikan pertama tidak berlaku sama sekali. Opsinya benar, letaknya yang salah. astro.config.mjs saat itu punya dua kunci vite, dan yang belakangan menimpa yang duluan. Sebagai ilustrasi yang disederhanakan:
// ilustrasi, disederhanakan: dua kunci vite dalam satu objek
export default defineConfig({
vite: { ssr: { noExternal: ['gsap'] } },
// ...
vite: { plugins: [tailwindcss()] }, // hanya kunci terakhir yang dipakai
});Objek literal JavaScript menerima kunci ganda tanpa galat, dan nilai yang ditulis belakangan menang. Objek yang dibaca Astro hanya punya satu kunci vite, yaitu yang terakhir, sehingga ssr.noExternal tidak pernah sampai ke sana.
Di riwayat git, keadaan dua kunci itu tidak terlihat. Versi astro.config.mjs yang ter-commit tepat sebelum perbaikan hanya memuat satu kunci vite berisi plugin Tailwind saja, dan baris itulah yang diganti commit perbaikannya. Jejaknya tinggal di pesan commit, yang mencatat bahwa percobaan pertama tidak berlaku sama sekali dan kuncinya kini tinggal satu, serta di komentar di atas kunci itu:
// astro.config.mjs, disederhanakan: opsi lain dihilangkan
export default defineConfig({
output: 'static',
adapter: vercel(),
/**
* SATU kunci `vite` saja. Sempat ada dua di berkas ini, dan yang belakangan
* diam-diam menimpa yang duluan, jadi `ssr.noExternal` tidak pernah berlaku
* sampai ketahuan lewat 500 di produksi.
*/
vite: {
plugins: [tailwindcss()],
ssr: {
// GSAP WAJIB dibundel, bukan dibiarkan external.
noExternal: ['gsap'],
},
},
});Gerbang yang lulus saat seharusnya gagal
Kegagalan semacam ini tidak menggagalkan build, jadi gerbang baru dipasang di postbuild untuk memeriksa bentuk paket yang masih external. Menurut pesan commit-nya, gerbang itu memeriksa tiap dependensi yang masih external di fungsi server, dan sasarannya bentuk persis yang membuat dasbor membalas 500 hari itu.
Versi pertama gerbang itu memuat tiap chunk di Node lokal, dan terbukti tidak menangkap apa pun. Ujinya dengan mengosongkan noExternal, dan gerbangnya tetap lulus, jadi pendekatan itu dibuang. Alasannya sudah terlihat di bagian sebelumnya: di mesin lokal, memuat GSAP memang berhasil.
Versi yang dipakai memeriksa bentuk paketnya, bukan mencoba memuatnya. Gerbang itu memindai keluaran server di .vercel/output/functions/_render.func/.vercel/output/server dan mengumpulkan seluruh specifier bare yang masih diimpor di sana. Kalau keluaran build tidak punya fungsi server, pemeriksaannya dilewati dengan kode keluar 0. Untuk tiap paket yang terkumpul, intinya kira-kira begini:
// inti pemeriksaan per paket, disederhanakan. Nama fungsi dan parameternya
// netral, dan contoh ini hanya memeriksa entri "." di peta exports.
function isRisky(pkg, readFile) {
if (pkg.type === 'module') return false;
const target = pkg.exports?.['.']?.import;
if (typeof target !== 'string' || !target.endsWith('.js')) return false;
// Pastikan berkasnya memang ESM, bukan sekadar bernama .js.
const head = readFile(target).slice(0, 2000);
return /^\s*(import|export)\s/m.test(head);
}Paket ber-type: module langsung lolos. Paket lain dianggap berbahaya kalau kondisi import-nya menunjuk berkas .js, karena menurut komentar gerbangnya, berkas itu akan dibaca sebagai CommonJS oleh loader yang memilih jalur require dan meledak kalau isinya ESM. Untuk memastikan berkasnya memang ESM, gerbang mencari import atau export di 2000 karakter pertamanya. Kalau ada temuan, gerbang memperingatkan bahwa build ini akan terlihat sukses tapi rute on-demand-nya membalas 500, lalu keluar dengan kode 1. Obat yang ditunjuknya selalu sama, yaitu menambahkan nama paketnya ke vite.ssr.noExternal supaya ikut dibundel.
Diterapkan pada package.json GSAP tadi, hasilnya tidak ambigu: tidak ada type, kondisi import di entri . menunjuk ./index.js, dan berkas itu dibuka dengan import, jadi GSAP yang dibiarkan external akan ditolak. Gerbang yang dipakai sekarang sudah dibuktikan dua arah, gagal saat noExternal kosong dan lulus setelah diisi, dan README repo-nya meminta postbuild yang menjalankannya tidak dilepas.
Yang terbukti sesudah perbaikan
Perbaikan noExternal dan gerbangnya masuk pada 8 September 2026. Bukti bahwa dasbornya pulih datang dari verifikasi butir terakhir tahap M6, ketika dasbor merender 300 baris pengunjung, dan dasbor itulah halaman yang sebelumnya membalas 500. Catatan saya tidak memuat baris uji HTTP yang secara harfiah menyebut status 200 sesudah noExternal, jadi buktinya adalah halaman yang merender datanya, bukan kode status yang tercatat.
Ada satu catatan lagi yang membuat kasus ini agak ganjil. Ceklis tahap M8 belakangan menangkap bahwa dasbor Astro merender cangkang penuh, padahal versi Next-nya merender dasbor itu telanjang, dan catatan itu menggolongkannya sebagai cacat, bukan kosmetik. Halaman yang pertama menyeret GSAP ke runtime server ternyata tidak seharusnya membawa cangkang itu. Gerbangnya tidak bergantung pada halaman pemicu, karena yang diperiksanya adalah setiap dependensi yang masih external di fungsi server.
Build hijau hanya membuktikan jalur build
Halaman statis yang sehat hanya membuktikan kode yang berjalan saat build. Rute on-demand berjalan di runtime lain, yang di kasus ini memuat GSAP dengan cara yang tidak bisa direproduksi mesin lokal, jadi rute on-demand pertama yang merender layout bersama perlu dibuka di deployment sungguhan. Paket yang ikut dimuat di fungsi server tanpa type: module, dengan kondisi import yang menunjuk berkas .js berisi ESM, lebih aman dibundel lewat vite.ssr.noExternal daripada diserahkan ke runtime fungsi.
Sesudah mengubah konfigurasi, pastikan opsinya benar-benar berlaku, karena kunci ganda di objek konfigurasi tidak memunculkan galat apa pun dan yang ditulis belakangan menang diam-diam.
Pelajaran yang saya catat dari gerbangnya: gerbang yang belum pernah terlihat gagal belum boleh dipercaya. Versi pertamanya tetap lulus walaupun noExternal dikosongkan, jadi lulusnya tidak membuktikan apa-apa.