A green build that produces a 15-byte index.html is a strange thing to look at. The file contains exactly this:
[object Object]
No HTML, no error, no warning, exit code 0. I ran into this while setting up a static site on Astro and Cloudflare Workers. More precisely, I ran into the bug report first: withastro/astro#18009 describes every prerendered page turning into [object Object] when nodejs_compat is enabled with a recent compatibility_date. The author found out in production. I wanted to know whether my setup was exposed before deciding how to guard against it, so I reproduced it locally.
The reproduction also turned up something the issue does not mention: the date at which the bug starts is not fixed. It depends on which versions of the toolchain you happen to install.
The reproduction
The issue comes with a four‑file reproduction, and I used it as is.
package.json:
{ "name": "cf-prerender-repro", "type": "module", "private": true, "scripts": { "build": "astro build" }, "dependencies": { "@astrojs/cloudflare": "14.3.1", "astro": "7.3.2", "wrangler": "4.131.2" } }
astro.config.mjs:
import { defineConfig } from 'astro/config'; import cloudflare from '@astrojs/cloudflare'; export default defineConfig({ output: 'server', adapter: cloudflare() });
src/pages/index.astro:
--- export const prerender = true; --- <html><body><h1>hello</h1></body></html>
And a wrangler.toml whose two settings I changed between runs:
name = "r" compatibility_date = "2025-10-01" compatibility_flags = ["nodejs_compat"]
After npm install, the resolved versions were astro 7.3.2, @astrojs/cloudflare 14.3.1, wrangler 4.131.2, workerd 1.20261006.1 and @cloudflare/unenv‑preset 2.16.2. Each run was rm -rf dist && npx astro build, then a look at dist/client/index.html.
Isolating it
compatibility_date | compatibility_flags | dist/client/index.html |
|---|---|---|
2025–10-01 | none | 55 bytes, correct HTML |
2025–10-01 | nodejs_compat | 15 bytes, |
2025–10-01 | nodejs_compat, disable_nodejs_process_v2 | 55 bytes, correct HTML |
2025–09-30 | nodejs_compat | 15 bytes |
2025–09-15 | nodejs_compat | 15 bytes |
2025–09-14 | nodejs_compat | 55 bytes, correct HTML |
2025–09-01 | nodejs_compat | 55 bytes, correct HTML |
Every one of these builds exited with code 0 and printed no warnings.
Three things follow from the table. nodejs_compat is required: without it, even a current date builds correctly. The disable_nodejs_process_v2 flag is a complete workaround, which points at the process global. And the date matters, but not in the way the issue describes.
The date boundary moved
The issue reports the boundary as 2025–09-30 working and 2025–10-01 broken. With the versions I got from npm, 2025–09-30 was already broken. Bisecting the dates put the boundary between 2025–09-14 and 2025–09-15.
I went looking for that date in node_modules and found it in @cloudflare/unenv-preset, the package that decides which Node.js APIs a Worker gets natively and which it gets as polyfills:
function getProcessOverrides({ compatibilityDate, compatibilityFlags }) { const disabledV2ByFlag = compatibilityFlags.includes("disable_nodejs_process_v2"); const enabledV2ByFlag = compatibilityFlags.includes("enable_nodejs_process_v2"); const enabledV2ByDate = compatibilityDate >= "2025-09-15"; // ... }
The same function also checks a second date, so I cannot claim this line alone decides the outcome. What I can say from the measurements is that the boundary in my environment is 2025–09-15, the issue reporter observed 2025–10-01, and the difference lines up with this kind of date constant living in a dependency that gets updated independently of your own config.
That changes how I think about the obvious workaround. “Pin compatibility_date to 2025–09-30” is advice that was correct for one combination of package versions and wrong for the next. A date pin protects you only as long as nobody upgrades anything underneath it.
Root cause
The fix landed in withastro/astro#18011, and its description explains the mechanism. Astro decides whether it is running on Node with this check:
const isNode = typeof process !== "undefined" && Object.prototype.toString.call(process) === "[object process]";
With nodejs_compat and the newer process implementation, workerd's process passes that check. So during prerendering, which runs inside workerd, Astro takes its Node streaming path. In 7.3.2 that path looks like this:
if (streaming) { if (isNode && !isDeno) { const nodeBody = await renderToAsyncIterable(result, componentFactory, props, children, true, route); body = nodeBody; } else { body = await renderToReadableStream(result, componentFactory, props, children, true, route); } }
The body is an AsyncIterable<Uint8Array>, which Node's Response accepts. Workerd's Response does not support AsyncIterable as a body type, so it converts the object to a string. String(someObject) is "[object Object]", fifteen characters, and that is what gets written to disk.
The issue author had already confirmed the corruption was present in the response coming back from the prerender worker, before Astro writes any file. That is also why the build stays green. The Response is constructed successfully with status 200, and from the build's point of view a page was rendered and saved.
The fix in 7.3.3 adds a second runtime check and wraps the iterable in a stream that workerd understands:
const isWorkerd = typeof navigator !== "undefined" && navigator.userAgent === "Cloudflare-Workers"; // inside the streaming branch if (isWorkerd && !(nodeBody instanceof Response)) { body = ReadableStream.from(nodeBody); } else { body = nodeBody; }
I upgraded the reproduction to astro 7.3.3 and @astrojs/cloudflare 14.3.2 and rebuilt with nodejs_compat at 2025–09-15, 2025–10-01 and 2026–09-01. All three produced the correct 55-byte page.
What I actually put in place
For the real project I ended up with three layers, and the date pin is not one of them that I rely on.
Do not enable nodejs_compat unless a dependency needs it. The site uses D1 through its Workers binding, which does not need any Node APIs. Without the flag, the bug cannot trigger regardless of the date. If you do need the flag, adding disable_nodejs_process_v2 fixed the reproduction, though it turns off the newer process implementation for your whole Worker.
Require astro >= 7.3.3. Anything below it reintroduces the bug the moment someone turns nodejs_compat on.
Check the build output itself. This is the layer I trust most, because it does not depend on knowing the cause. The build script runs a small check after astro build:
// check-prerender.mjs: fail if any prerendered HTML is tiny or contains "[object Object]". import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; const DIST = process.argv[2] ?? 'dist'; const MIN_BYTES = 200; function* walk(dir) { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) yield* walk(full); else if (entry.name.endsWith('.html')) yield full; } } const problems = []; let count = 0; for (const file of walk(DIST)) { count++; const size = statSync(file).size; if (size < MIN_BYTES) problems.push(`${file}: only ${size} bytes`); else if (readFileSync(file, 'utf8').includes('[object Object]')) problems.push(`${file}: contains "[object Object]"`); } if (count === 0) problems.push(`no .html files under ${DIST}`); if (problems.length > 0) { console.error(`check-prerender: ${problems.length} problem(s)\n ${problems.join('\n ')}`); process.exit(1); } console.log(`check-prerender: ${count} HTML file(s) OK`);
wired in as "build": "astro build && node check-prerender.mjs". Against the reproduction, with a page body long enough to clear the size threshold:
astro 7.3.3: build exit 0, check-prerender: 1 HTML file(s) OK astro 7.3.2: build exit 0, check-prerender: 1 problem(s) dist/client/index.html: only 15 bytes check exit 1
The 200-byte threshold is arbitrary. Any real page of mine is far larger, and the broken output is always exactly 15 bytes, so there is a lot of room. The empty‑directory case is checked as well, because a build that silently produces nothing is the same class of failure.
The production version of this script has grown other checks since, but this part has not changed. It catches this specific bug, and it would catch the next one that breaks prerendering in a different way, as long as the result is a page that is obviously too small or contains a stringified object.
Takeaways
A compatibility date is not a stable boundary. The same date behaved differently across two sets of package versions a few weeks apart.
nodejs_compatchanges more than which modules you can import. Here it changed how a framework detected its runtime.“The build passed” and “the output is correct” are separate claims. When the failure mode is silent, the cheapest defense is an assertion on the artifacts you are about to deploy.