An Astro 6 React island hydrating inside Cloudflare workerd, with the Vite optimizer pre-bundling react and react-dom on the SSR path.

How I Migrated From Astro 5 to 6 With All My React Islands

A first-person migration log: every error I hit moving an Astro 5 site with React islands to Astro 6 on Cloudflare Workers - Invalid hook call, the dependency scan crash, the i18n ghost - with the exact logs, causes, and fixes.

#astro #astro 6 #cloudflare workers #react #vite #migration #ssr #debugging

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:

  1. It only failed on the first load. A browser refresh rendered the page fine.
  2. 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 return into template conditionals (or accept the harmless scan warning).
  • Configure the SSR optimizer through a configEnvironment plugin, not ssr.optimizeDeps.
  • resolve.dedupe React, and alias react-dom/server to react-dom/server.edge in dev too.
  • Pre-include every island dependency so the optimizer runs once at startup.
  • exclude the astro:i18n virtual 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_first in wrangler.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.

Frequently Asked Questions

Why does Astro 6 with the Cloudflare adapter throw "Invalid hook call" when Astro 5 did not?

Astro 6 runs the dev server SSR inside Cloudflare workerd through @cloudflare/vite-plugin, optimizing server dependencies into a deps_ssr folder. When the dependency scan fails, Vite discovers deps lazily — each discovery re-runs optimization and reloads the worker. React and react-dom/server land in separate optimize passes, so after a reload react-dom/server holds a React instance whose hook dispatcher is null, and calling useState reads that null. Running npm ls react still shows a single deduped react@19: the split is a timing artifact in the optimizer, not a real duplicate on disk. Pre-bundle every island dependency in one startup pass and exclude astro:i18n, and the error disappears.

How do I fix the "Invalid hook call" error with React islands in Astro 6 on Cloudflare Workers?

Stop Vite from optimizing dependencies lazily so React never desyncs. Four steps: register a configEnvironment Vite plugin that pre-bundles every island dependency for the non-client (workerd) environment in one pass at startup; set resolve.dedupe to ["react", "react-dom"] to keep a single React instance across the client and worker graphs; alias react-dom/server to react-dom/server.edge so dev uses the Web-Streams build that workerd needs; and exclude the astro:i18n virtual module so it stops triggering a late reload. Clear node_modules/.vite between attempts, because stale chunks will lie to you. After this, the dev server boots clean and every island hydrates on the first load.

What causes "Failed to run dependency scan" and "Unexpected newline after throw" in an Astro project?

A top-level early return in component frontmatter. Astro compiles a bare return in the frontmatter into a throw, and the esbuild dependency scanner trips on JavaScript automatic semicolon insertion: a throw cannot be followed by a newline. A single offending .astro file aborts the scan for the entire project. The dangerous part is the knock-on effect: with the scan dead, Vite stops pre-bundling and starts discovering dependencies lazily, one per request, which is the root cause of the later Invalid hook call and astro:i18n crashes. Fix it by moving the condition out of the frontmatter into a template conditional, or by pre-declaring dependencies so the failed scan no longer matters.

Why is vite.ssr.optimizeDeps ignored under the Astro Cloudflare adapter, and what replaces it?

Under @cloudflare/vite-plugin, SSR is its own Vite environment, and the legacy vite.ssr.optimizeDeps config never reaches it, so adding your dependencies there changes nothing. The documented way to configure the optimizer for that environment is a small Vite plugin using the configEnvironment hook: return { optimizeDeps: { include: [...] } } for every environment whose name is not "client". This was the single biggest time-sink in the migration, because the wrong knob fails silently instead of erroring. Use configEnvironment for the workerd graph and the top-level vite.optimizeDeps.include for the browser graph.

How do I pre-bundle React island dependencies for the workerd SSR environment so the optimizer runs once?

List every island dependency — react, react-dom, react-dom/server.edge, react/jsx-runtime, plus your form, icon, and UI libraries — in a shared constant, then feed it to optimizeDeps.include through a configEnvironment plugin for the non-client environment, and to vite.optimizeDeps.include for the client graph. The goal is one optimize pass at startup, so there is no lazy discovery, no reload mid-render, and nothing to desync. Anything discovered late — Astro internals such as astro/zod or astro/actions/runtime, or a newly added package like @phosphor-icons/react — can re-trigger the cascade, so add new island dependencies to both lists as you introduce them.

When should I use react-dom/server.edge instead of react-dom/server in Astro on Cloudflare?

Use react-dom/server.edge everywhere, in development as well as production, once the dev server runs in workerd — which is the default in Astro 6 with the Cloudflare adapter. The .edge entry is the Web-Streams build that the Workers runtime requires, whereas the default react-dom/server entry assumes Node streams. In Astro 5 you only needed the edge build in the production build, because dev ran in Node. In Astro 6, alias react-dom/server to react-dom/server.edge in vite.resolve.alias so both graphs resolve the same build and the optimizer has nothing to split.

Why does the astro:i18n virtual module reload the worker, and how do I stop it?

astro:i18n (astro/virtual-modules/i18n.js) is a virtual module, so it has no real file on disk to pre-bundle through optimizeDeps.include. Pulled in by the i18n: {} block in your config, it gets optimized lazily on first render, reloads the worker mid-request, and leaves the app config object undefined — so reading .i18n off it throws "Cannot read properties of undefined (reading i18n)". The fix is to tell the optimizer to leave it alone with optimizeDeps.exclude: ["astro:i18n", "astro/virtual-modules/i18n.js"]. No optimization means no reload, which means the React chunks stay put. A custom KV-based i18n engine is not the culprit here; this is the built-in Astro virtual module.

Do I need @astrojs/react v6 for Astro 6?

No. @astrojs/react 5.0.5 is the current stable release and already depends on Vite 7 and @vitejs/plugin-react 5, so it works with Astro 6. The only newer thing on npm is a 6.0.0-alpha, which you do not need. The migration pain in Astro 6 comes from the dev server moving into workerd and how Vite optimizes dependencies for that environment, not from the React integration version.

How do I import @phosphor-icons/react in Astro without the dist/ssr resolution error?

Import from the main entry, @phosphor-icons/react, and drop the dist/ssr subpath entirely. 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 import works in both .astro files and React islands with no bundle-size penalty: in .astro files the icon renders server-side and ships only HTML, and in islands named imports tree-shake. Add @phosphor-icons/react to both the client and server optimizeDeps include lists, because it is one more island dependency the SSR pass meets on first render.

Why does Astro 6 run the dev server inside workerd, and why does that surface React island bugs Astro 5 hid?

Astro 6 moved the dev server into workerd, via @cloudflare/vite-plugin, so local development finally mirrors production on Cloudflare Workers instead of running in Node and behaving differently once deployed. The trade-off is that workerd is stricter: it needs the react-dom/server.edge Web-Streams build, and it is sensitive to the lazy dependency optimization in Vite, which can split React across optimize passes and reload the worker mid-render. None of these were new bugs in your code — they were latent mismatches that the Node dev server papered over. The general lesson: when dev matches prod, you pay the real runtime costs during development instead of discovering them after deploy.

We use cookies to analyze site traffic and improve your experience. By clicking "Accept All", you consent to our use of analytical tracking.