While every page of this site was rendered at build time, one path was never exercised: GSAP being loaded inside a Vercel server function. That path only came into use when the first on-demand page appeared, and that page returned 500 while the build was green and all 506 static pages were healthy.
The page was a private visitor dashboard, and it happened on 8 September 2026, in the middle of rebuilding this site from Next.js to Astro. The architecture decided for the new site was output: 'static' with the Vercel adapter, with prerender = false on only 20 routes. Dependencies had been pinned since the initial spike that same day, astro 7.3.1 and @astrojs/vercel 11.0.10, and GSAP was pinned at exactly 3.14.2.
My post on Can't resolve '@sanity/ui' was about a failed build hidden behind an older version that kept being served. This case runs the other way. The build really did succeed, the deploy carrying the dashboard really did land, and the failure only showed up when one server function was invoked. Nor was the root cause a dependency that never got declared. It was the shape of GSAP's package.json, which the Vercel function runtime read differently.
One on-demand page among 506 static ones
The rebuild was split into numbered stages. Up to stage M5, every page was rendered at build time. The M6 commit added visitor tracking, four endpoints, and the private dashboard, and afterwards the build output became hybrid: 506 pages stayed static and were served from the filesystem, while only five routes reached the server function.
Four endpoints plus one page makes exactly five. The four endpoints had to set prerender = false, because an endpoint that does not opt out gets executed at build time and frozen into a static file. There was only one page, the dashboard, which had to be on-demand as well because it reads a cookie on every request. Built statically, it would freeze a single render branch for everyone.
At that stage the main domain was still serving the Next site and only moved to Astro later that same day, so this 500 happened on the production deployment of the Astro project on Vercel. That project has its own public alias, which has carried X-Robots-Tag: noindex, nofollow since the first deploy.
From the dashboard to import gsap
GSAP reached the server function through the site layout. The dashboard page imports that layout and renders its content inside the layout. The layout renders an intro component with the client:load directive, and that component imports gsap. Simplified, the chain looks like this:
dashboard page prerender = false, rendered per request
-> Layout.astro the layout other pages render too
-> <Intro client:load />
-> import gsap from "gsap"
simplified; Layout and Intro are neutral names, not the real file namesA component with client:load still gets rendered on the server, and that topic has a post of its own. One consequence is enough here: the intro component's module, including its import gsap, is evaluated wherever the layout is rendered.
For static pages, that evaluation happens at build time, and Node at build time loads ESM without trouble. The dashboard was the first on-demand page to render the layout, so it was the first to drag GSAP into the server runtime.
A package without type pointing at an ESM file
I summed up the cause in a config comment that went in with the fix. The package declares itself commonjs, but its exports map points to an index.js whose content is ESM, so Node in the serverless function loads it as CommonJS and immediately throws "Cannot use import statement outside a module". I am quoting that error message from the comment, because the Vercel function log itself is not in my notes.
The package's shape can be checked directly. These are the relevant fields from the installed GSAP 3.14.2 package.json, trimmed from the original file:
{
"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"
}
}
}What is missing from that excerpt matters just as much, namely a top-level type field. The only "type" key in the file sits inside the repository block, with the value git. So the phrase "declares itself commonjs" in my comment is better read as "does not declare itself ESM". Meanwhile, index.js opens with an ESM import statement from ./gsap-core.js.
The import condition in an exports map only picks which file gets used, not how the loader reads that file. Node does not read the module field at all. That field is a convention for bundlers, which fits another line of the same comment: GSAP relies on a bundler, and here the bundler has to be told explicitly.
What makes this slippery is that the local machine does not reproduce it. Tested directly there, both import() and require() of gsap succeed. A comment in the build gate I added after this incident attributes this to condition resolution differing between the local machine and the Vercel launcher, and my notes hold no more detailed explanation than that.
ssr.noExternal, and a vite key written twice
By default, Vite's SSR build leaves dependencies from node_modules external. The server output keeps an import of the package name, and the package is only loaded when the function runs. The fix is vite.ssr.noExternal: ['gsap'], which forces GSAP into the bundle. The bundler then copies GSAP's code into a server chunk, so the function runtime no longer has to load it from node_modules.
The first fix did not take effect at all. The option was right and its placement was wrong. astro.config.mjs had two vite keys at the time, and the later one overrode the earlier one. As a simplified illustration:
// illustration, simplified: two vite keys in one object
export default defineConfig({
vite: { ssr: { noExternal: ['gsap'] } },
// ...
vite: { plugins: [tailwindcss()] }, // only the last key is used
});A JavaScript object literal accepts duplicate keys without an error, and the value written later wins. The object Astro reads has a single vite key, the last one, so ssr.noExternal never makes it there.
Git history does not show the two-key state. The version of astro.config.mjs committed just before the fix holds a single vite key containing only the Tailwind plugin, and that is the line the fix commit replaced. The trace survives in the commit message, which records that the first attempt did not take effect at all and that there is now only one key, and in the comment above that key:
// astro.config.mjs, simplified: other options removed, comments translated
export default defineConfig({
output: 'static',
adapter: vercel(),
/**
* ONE `vite` key only. There used to be two in this file, and the later one
* silently overrode the earlier one, so `ssr.noExternal` never applied
* until a 500 in production gave it away.
*/
vite: {
plugins: [tailwindcss()],
ssr: {
// GSAP MUST be bundled, not left external.
noExternal: ['gsap'],
},
},
});The gate that passed when it should have failed
A failure like this does not fail the build, so a new gate went into postbuild to check the shape of packages that are still external. According to its commit message, the gate checks every dependency still external in the server function, and its target is exactly the shape that made the dashboard return 500 that day.
The first version of the gate loaded each chunk in local Node, and it was shown to catch nothing. The test was to empty noExternal, and the gate still passed, so that approach was dropped. The reason is already visible in the previous section: on the local machine, loading GSAP does succeed.
The version in use checks the shape of each package instead of trying to load it. It scans the server output at .vercel/output/functions/_render.func/.vercel/output/server and collects every bare specifier still imported there. If the build output has no server function, the check is skipped with exit code 0. For each package it collects, the core looks roughly like this:
// core per-package check, simplified. The function and parameter names
// are neutral, and this example only checks the "." entry of the exports map.
function isRisky(pkg, readFile) {
if (pkg.type === 'module') return false;
const target = pkg.exports?.['.']?.import;
if (typeof target !== 'string' || !target.endsWith('.js')) return false;
// Make sure the file really is ESM, not merely named .js.
const head = readFile(target).slice(0, 2000);
return /^\s*(import|export)\s/m.test(head);
}Packages with type: module pass straight away. Any other package counts as dangerous when its import condition points to a .js file, because, as the gate's comment puts it, that file will be read as CommonJS by a loader that takes the require path and will blow up if its content is ESM. To confirm the file really is ESM, the gate looks for import or export in its first 2000 characters. On a hit, the gate warns that the build will look successful while its on-demand routes return 500, then exits with code 1. The remedy it points to is always the same, which is adding the package name to vite.ssr.noExternal so it gets bundled.
Applied to the GSAP package.json above, the answer is unambiguous: there is no type, the import condition of the . entry points to ./index.js, and that file opens with import, so GSAP left external gets rejected. The gate in use has been proven in both directions, failing when noExternal is empty and passing once it is filled, and the repo's README asks that the postbuild step running it not be removed.
What was proven after the fix
The noExternal fix and its gate went in on 8 September 2026. The evidence that the dashboard recovered comes from the final check of stage M6, when the dashboard rendered 300 visitor rows, and the dashboard was the very page that had returned 500. My notes contain no HTTP test line that literally reports a 200 after noExternal, so the evidence is a page rendering its data, not a recorded status code.
One more note makes this case a little odd. The stage M8 checklist later caught that the Astro dashboard rendered the full layout while the Next version rendered that dashboard bare, and the notes classify this as a defect, not a cosmetic issue. The page that first dragged GSAP into the server runtime turned out to be a page that should not have carried that layout at all. The gate does not depend on which page triggers it, because what it checks is every dependency still external in the server function.
A green build only proves the build path
Healthy static pages only prove the code that runs at build time. On-demand routes run in a different runtime, which in this case loaded GSAP in a way the local machine could not reproduce, so the first on-demand route that renders a shared layout needs to be opened on a real deployment. A package loaded in a server function without type: module, whose import condition points to a .js file containing ESM, is safer bundled through vite.ssr.noExternal than left to the function runtime.
After changing the config, confirm the option really applies, because a duplicate key in a config object raises no error at all and the one written later silently wins.
The lesson I wrote down from the gate: a gate that has never been seen failing cannot be trusted yet. Its first version still passed with noExternal emptied, so its passing proved nothing.