I run EdgeKits on the full Cloudflare stack, and the marketing site is mostly Astro with a handful of React islands - a newsletter form, a toaster, a desktop nav. Astro 6 moved the dev server into workerd so local development finally mirrors production. I expected a version bump. I got an afternoon of debugging.
Here is every error I hit, in the order I met it, with the log that produced it, why it happened, and the exact fix. If you are doing an Astro 5 to 6 migration with React islands on Cloudflare Workers, this is the map I wish I'd had.
My stack: Astro 6.3.6, @astrojs/cloudflare 13.5.x, @astrojs/react 5.0.5, React 19, Vite 7, Tailwind 4, output: 'server' on Workers.
First wall: the Vite dependency scan crashes
The very first npm run dev printed this before the server was even ready:
[ERROR] [vite] (!) Failed to run dependency scan. Skipping dependency pre-bundling.
X [ERROR] Unexpected newline after "throw"
src/domain/analytics/components/ConsentBanner.astro:7:24
7 | if (consentStatus) throw
The confusing part: that line in my source says return, not throw. I never wrote a throw there.
The cause. Astro compiles a top-level early return in component frontmatter into a throw. esbuild's dependency scanner then tries to parse the compiled output and trips on JavaScript's automatic-semicolon-insertion rule: throw cannot be followed by a newline. A single offending file aborts the scan for the entire project.
The knock-on effect is the dangerous part. With the scan dead, Vite stops pre-bundling and starts discovering dependencies lazily - one at a time, on each request. Remember that; it is the root of the next two crashes.
The fix. Move the condition out of the frontmatter and into the template:
---
// before: if (consentStatus) return
const showBanner = !consentStatus
---
{showBanner && <ConsentUI />}
Do the same anywhere you have a bare early return in frontmatter. (You can also lean on the dependency pre-declaration from the next section, which makes the failed scan harmless - but cleaning up the returns is the tidy fix.)
The boss fight: Invalid hook call in Astro 6 on Cloudflare
With the site loading, the first page render exploded:
[ERROR] [vite] Invalid hook call. Hooks can only be called inside
of the body of a function component.
[ERROR] [vite] TypeError: Cannot read properties of null (reading 'useState')
at useState (.../.vite/deps_ssr/chunk-EMAOOZFV.js)
at useSubscribeNewsletter (src/hooks/useSubscribeNewsletter.ts)
at NewsletterFlow (src/components/layout/islands/NewsletterFlow.tsx)
The React message lists three suspects: mismatched versions, broken rules of hooks, or more than one copy of React. None of them was true in the usual sense. Two clues told me this was a dev-server problem, not my code:
- It only failed on the first load. A browser refresh rendered the page fine.
- Switching to the Node adapter made it vanish completely.
The cause. In Astro 6 the Cloudflare adapter runs SSR inside workerd via @cloudflare/vite-plugin. Vite optimizes server dependencies into a deps_ssr folder. Because my dependency scan had failed, deps were being discovered lazily; every discovery re-ran optimization and reloaded the worker. React and react-dom/server landed in different optimize passes, and after a reload react-dom/server held a reference to a React instance whose hook dispatcher was now null. Calling useState read that null.
That is the "more than one copy of React" warning - except the split was in the optimizer, not on disk. npm ls react showed a single, deduped react@19. The duplication was a timing artifact, not a packaging one.
The fix that did not work: vite.ssr.optimizeDeps
My first instinct was to list the deps in vite.ssr.optimizeDeps.include. Nothing changed. That cost me a while, so learn from it:
With the Cloudflare plugin, SSR is its own Vite environment, and vite.ssr.optimizeDeps never reaches it. The documented way to configure that environment's optimizer is a small Vite plugin using the configEnvironment hook.
The config that worked: a configEnvironment optimizer plugin
A tiny plugin that pre-bundles the server graph for every non-client environment:
const SERVER_OPTIMIZE_DEPS = [
'react',
'react-dom',
'react-dom/server.edge',
'react/jsx-runtime',
'react-hook-form',
'@hookform/resolvers/zod',
'sonner',
'cmdk',
'radix-ui',
'class-variance-authority',
'tailwind-merge',
'zod',
'drizzle-orm',
'drizzle-orm/d1',
'@astrojs/rss',
]
function optimizeServerDeps() {
return {
name: 'optimize-server-deps',
configEnvironment(name) {
if (name !== 'client') {
return { optimizeDeps: { include: SERVER_OPTIMIZE_DEPS } }
}
},
}
}
Then two lines in vite.resolve that matter as much as the include list:
resolve: {
dedupe: ['react', 'react-dom'],
alias: { 'react-dom/server': 'react-dom/server.edge' },
}
dedupe keeps a single React instance across the client and worker graphs. The alias forces the Web-Streams ("edge") build of react-dom/server everywhere. In Astro 5 I only needed that build in production; now that dev runs in workerd, it has to be the edge build in dev too.
The whole idea: get every dependency optimized in one pass at startup, so there is no lazy discovery, no reload mid-render, and nothing to desync. Add the same list to the client-side vite.optimizeDeps.include so the browser graph doesn't churn either.
The i18n ghost: the astro:i18n virtual module forces one last reload
After that, the reload cascade shrank to a single line - and a new crash:
[vite] new dependencies optimized: astro/virtual-modules/i18n.js
[vite] reloading
[ERROR] Cannot read properties of undefined (reading 'i18n')
at getComponentByRoute (.../deps_ssr/astro_app_entrypoint_dev.js)
Same disease, different mask. The one remaining late reload came from Astro's own astro:i18n virtual module, pulled in by the i18n: {} block in my config. The reload re-instantiated the worker mid-request and left the app config object undefined - so reading .i18n off it threw.
The catch: it is a virtual module, so I can't pre-bundle it through include. But I can tell the optimizer to leave it alone:
optimizeDeps: {
include: SERVER_OPTIMIZE_DEPS,
exclude: ['astro:i18n', 'astro/virtual-modules/i18n.js'],
}
No optimization for that module means no reload, which means the React chunks stay put. Both crashes gone. This was also the moment I confirmed my custom KV-based i18n engine was never the culprit - it was Astro's built-in virtual module all along.
React icons brought it back: @phosphor-icons/react
Adding @phosphor-icons/react reintroduced the cascade risk, because it is one more island dependency the SSR pass meets on first render. The fix is identical: add it to both the client and server include lists.
One trap I walked straight into: the docs-friendly @phosphor-icons/react/dist/ssr subpath throws type and resolution errors under moduleResolution: "bundler", which Vite and Astro use by default. The plain @phosphor-icons/react import works in both .astro files and React islands, so I dropped the dist/ssr subpath entirely. In .astro files the icon renders server-side and ships only HTML; in islands, named imports tree-shake. No bundle-size penalty either way.
The whole astro.config.mjs, in one place
Here is the migration-relevant shape of my config - the two helper constants, the optimizer plugin, and where they plug into defineConfig. Everything unrelated (fonts, image, i18n locales, markdown) is unchanged from Astro 5 and trimmed out:
// astro.config.mjs
import { defineConfig } from 'astro/config'
import cloudflare from '@astrojs/cloudflare'
import tailwindcss from '@tailwindcss/vite'
import react from '@astrojs/react'
import mdx from '@astrojs/mdx'
// Pre-bundle these for the SSR/workerd environment in ONE pass at startup, so
// Vite never discovers them lazily and reloads the worker mid-render.
const SERVER_OPTIMIZE_DEPS = [
'react',
'react-dom',
'react-dom/server.edge',
'react-dom/client',
'react/jsx-runtime',
'react-hook-form',
'@hookform/resolvers/zod',
'sonner',
'cmdk',
'radix-ui',
'@phosphor-icons/react',
'class-variance-authority',
'tailwind-merge',
'clsx',
'zod',
'drizzle-orm',
'drizzle-orm/d1',
'drizzle-orm/sqlite-core',
'drizzle-zod',
'@astrojs/rss',
// Astro internals that were optimized lazily right before the i18n crash.
// Real package exports, safe to pre-bundle.
'astro/zod',
'astro/assets/services/noop',
'astro/actions/runtime/entrypoints/server.js',
]
// Virtual modules the optimizer must NOT touch. Excluding astro:i18n kills the
// last late reload - the one that desynced React and threw "Invalid hook call".
const SERVER_OPTIMIZE_EXCLUDE = ['astro:i18n', 'astro/virtual-modules/i18n.js']
// SSR is its own Vite environment under @cloudflare/vite-plugin. The only way to
// reach its optimizer is configEnvironment - vite.ssr.optimizeDeps is ignored.
function optimizeServerDeps() {
return {
name: 'optimize-server-deps',
/** @param {string} name */
configEnvironment(name) {
if (name !== 'client') {
return {
optimizeDeps: {
include: SERVER_OPTIMIZE_DEPS,
exclude: SERVER_OPTIMIZE_EXCLUDE,
},
}
}
},
}
}
export default defineConfig({
adapter: cloudflare({
configPath: 'wrangler.jsonc',
imageService: 'cloudflare',
}),
integrations: [react(), mdx()],
// site, image, i18n, markdown - unchanged from Astro 5, omitted here.
vite: {
plugins: [tailwindcss(), optimizeServerDeps()],
resolve: {
dedupe: ['react', 'react-dom'],
// dev now runs in workerd, so use the edge build of react-dom/server everywhere
alias: { 'react-dom/server': 'react-dom/server.edge' },
},
// same list for the client graph so the browser doesn't churn either
optimizeDeps: {
include: [
'react',
'react-dom',
'react-dom/client',
'react/jsx-runtime',
'react-hook-form',
'@hookform/resolvers/zod',
'sonner',
'cmdk',
'radix-ui',
'@phosphor-icons/react',
'class-variance-authority',
'tailwind-merge',
],
},
ssr: {
// cookie is CommonJS and chokes workerd's optimizer - keep it external
external: [
'async_hooks',
'node:fs',
'node:path',
'node:url',
'node:crypto',
'cookie',
],
},
},
output: 'server',
build: { inlineStylesheets: 'always' }, // kills the render-blocking CSS request
})
The production-only twist: a cold hit served the homepage
Local dev was clean, so I deployed. Then a direct hit on an article URL did something I could never reproduce on my machine: the first request to /en/blog/<slug>/ returned the homepage, and only a second request showed the article. An external link landing cold on the page did the same. Refresh, and it behaved. The classic "works on the second try."
The i18n middleware was the obvious suspect, since it does locale redirects, so I instrumented every context.redirect with a log line and watched wrangler tail. On a valid deep URL: nothing. The middleware never ran. That was the tell.
The cause. My wrangler.jsonc had run_worker_first: false. With that, Cloudflare's static-asset layer answers before the Worker. On a cold hit to an SSR route with no matching static file, that layer short-circuited and served the landing page - my middleware and SSR never executed. The warm second request reached the Worker and rendered the article. It is also why dev never showed it: there is no Cloudflare asset layer in astro dev, so the middleware always runs locally.
The fix. One line, so the Worker - and the middleware it carries - runs first on every route:
// wrangler.jsonc
"assets": { "directory": "./dist", "binding": "ASSETS", "run_worker_first": true }
Redeployed, hit the URL cold: article on the first try. But true has a cost - now every request, including each /_astro/* chunk, font, and image, travels through the Worker instead of being served straight from the asset layer. On a mostly-static site that is wasted latency.
So the setting I actually shipped is the array form, which runs the Worker first only for page routes and lets static assets bypass it:
// wrangler.jsonc
"assets": { "directory": "./dist", "binding": "ASSETS", "run_worker_first": ["/*", "!/_astro/*"] }
Pages still hit the Worker first - middleware runs, no homepage fallback - while /_astro/* goes straight to the asset layer. I confirmed it with wrangler tail: under true every asset request showed up in the Worker logs; with the array, the /_astro/* lines vanish and only page routes remain. Same fix for the bug, asset latency back to normal.
The short checklist: Astro 5 → 6 with React islands on Cloudflare
If you are migrating Astro 5 → 6 with React islands on Cloudflare, this is the whole thing on one page:
- Move frontmatter early
returninto template conditionals (or accept the harmless scan warning). - Configure the SSR optimizer through a
configEnvironmentplugin, notssr.optimizeDeps. -
resolve.dedupeReact, and aliasreact-dom/servertoreact-dom/server.edgein dev too. - Pre-include every island dependency so the optimizer runs once at startup.
-
excludetheastro:i18nvirtual module to kill the last reload. - Import Phosphor icons from the main entry, never
/dist/ssr. -
inlineStylesheets: 'always'clears the CSS render-block. - Make the Worker run first on page routes via
run_worker_firstinwrangler.jsonc- use the array["/*", "!/_astro/*"]so pages get the middleware but/_astro/*still serves straight from the asset layer. Otherwise the SSR middleware silently won't run on a cold hit and Cloudflare serves the landing page instead.
Clear the optimizer cache (node_modules/.vite) between attempts - stale chunks will lie to you. After all of it: the dev server boots clean, every island hydrates on the first load, and PageSpeed lands in the high 90s on mobile and around 90 on desktop.
Top comments (0)