Skip to content

4. phaze-astro

1. phaze-tsplugin ← editor (TS Language Service)
2. phaze-compile ← build-time AST rewriting
├── babel-plugin.ts ← the actual Babel plugin (all the visitors live here)
└── vite-plugin.ts ← thin wrapper that adapts it for Vite
3. phaze-vite ← island HMR + chunking helpers
4. phaze-astro ← Astro integration (island model) ← you are here
5. phaze-cloudflare ← native Cloudflare Workers adapter (whole-page)

@madenowhere/phaze-astro is the tool you actually install when you want to use Phaze in an Astro app. It’s the highest-level layer in the tooling stack — its job is to register Phaze as a JSX renderer with Astro and to pull in the lower layers (phaze-compile, phaze-vite) so the app author doesn’t have to wire them up by hand.

If you’re building a non-Astro Vite app, you don’t need this — you use phaze-compile + phaze-vite directly. phaze-astro is specifically the Astro framework glue.

Astro integrations are plugins that hook into Astro’s build lifecycle via named hooks: astro:config:setup, astro:server:setup, astro:build:done, etc. Each hook gives the integration access to Astro’s internal config + Vite config, lets it call helper APIs like addRenderer() to register a JSX runtime, and lets it call updateConfig() to inject Vite plugins or modify build settings.

phaze-astro implements one hook: astro:config:setup. Everything it needs to do happens at config-resolution time, before Astro starts the dev server or build:

// packages/astro/src/index.ts (abbreviated)
export default function phazeIntegration() {
return {
name: '@madenowhere/phaze-astro',
hooks: {
'astro:config:setup': ({ addRenderer, updateConfig }) => {
// 1. Register the Phaze JSX renderer
addRenderer({
name: '@madenowhere/phaze-astro',
clientEntrypoint: '@madenowhere/phaze-astro/client',
serverEntrypoint: '@madenowhere/phaze-astro/server',
})
// 2. Wire phaze-compile into Vite's plugin chain
updateConfig({
vite: {
plugins: [phazeCompile()],
esbuild: {
jsx: 'automatic',
jsxImportSource: '@madenowhere/phaze',
},
optimizeDeps: { exclude: ['phaze', 'phaze/jsx-runtime', 'phaze/store'] },
ssr: {
noExternal: [
'@madenowhere/phaze',
'@madenowhere/phaze-astro',
'@madenowhere/phaze-render-to-string',
'@madenowhere/phaze-compile',
],
},
},
})
},
},
}
}

Two responsibilities — both happen at the astro:config:setup hook:

Responsibility 1 — Register the JSX renderer

Section titled “Responsibility 1 — Register the JSX renderer”

addRenderer({...}) tells Astro: “when a JSX-shaped island (<MyComponent client:load/>) needs to render, here are the modules you call.” Astro uses framework renderers to support React, Preact, Vue, Solid, Lit, and now Phaze — all interchangeable via the same hook.

  • clientEntrypoint: '@madenowhere/phaze-astro/client' — Astro loads this in the client bundle for client:load / client:idle / client:visible / client:media islands. The entrypoint exports a function that mounts a Phaze component into the island’s container, picking hydrate vs render based on whether the container has SSR’d children.
  • serverEntrypoint: '@madenowhere/phaze-astro/server' — Astro loads this during SSR (when generating HTML for client:load and friends). The entrypoint exports a renderToStaticMarkup function that delegates to @madenowhere/phaze-render-to-string’s real SSR renderer.

Once registered, every .tsx file in the project compiles through Phaze’s JSX runtime, every island mounts via Phaze’s render/hydrate, every SSR island renders to HTML via the linkedom-backed renderToString. The framework slot Astro provides is filled completely by Phaze without app-author code.

Responsibility 2 — Wire phaze-compile into Vite

Section titled “Responsibility 2 — Wire phaze-compile into Vite”

updateConfig({ vite: { plugins: [phazeCompile()], ... } }) injects phaze-compile’s Vite plugin into Vite’s plugin chain. After this runs, every .tsx/.jsx file in your project goes through babel-plugin.ts in phaze-compiler before reaching esbuild’s JSX-to-jsx() pass.

The accompanying config:

KeyWhy
esbuild.jsx: 'automatic'Tells esbuild to use the automatic-runtime JSX form (import jsx/jsxs/Fragment automatically), not the classic React.createElement form.
esbuild.jsxImportSource: '@madenowhere/phaze'The package whose /jsx-runtime subpath provides those auto-imported jsx/jsxs/Fragment functions. Phaze ships these.
optimizeDeps.excludeStops Vite from pre-bundling phaze (workspace ESM; pre-bundling races with workspace rebuilds).
ssr.noExternalForces Vite to run phaze packages through its SSR transform — without this, Astro 6’s default externalization loads them as raw Node ESM, where import.meta.env.DEV is undefined and the first reactive primitive crashes.

App authors writing astro.config.mjs see this as a single line:

import phaze from '@madenowhere/phaze-astro'
defineConfig({
integrations: [phaze()], // ← does all of the above behind one call
})

That one call is the integration story. Everything from babel-plugin.ts in phaze-compiler being in the Vite pipeline to JSX islands rendering through Phaze’s render happens because of it.

A few things you might expect to find here but don’t:

  • HMR injection — that’s phaze-vite’s phazeVite() plugin. The Astro integration doesn’t add it automatically because HMR is an opt-in dev-only feature with a one-line config cost; app authors who want it add phazeVite() to vite.plugins themselves.
  • phazeChunks() — also from phaze-vite. The integration doesn’t set a manualChunks default because chunk layout is an app-level decision (how much do you care about cache granularity vs first-paint bytes?).
  • JSX type augmentations for the use: / class: / bind: / for: namespaces — those live in @madenowhere/phaze’s types/jsx.ts (the runtime’s own types). The integration doesn’t ship them because they’re needed by .tsx files whether or not Astro is in the picture.

The split is intentional: phaze-astro is only the Astro-specific glue. Everything that could conceivably apply to a non-Astro Vite app lives in phaze-vite or phaze-compile.

The other thing phaze-astro ships is @madenowhere/phaze-astro/actions, a small helper that adapts Astro’s typed Actions API to Phaze signals:

import { useAction } from '@madenowhere/phaze-astro/actions'
import { actions } from 'astro:actions'
const submit = useAction(actions.requestWaitlistCode)
// submit.pending() → reactive boolean
// submit.error() → reactive error or null
// submit.data() → reactive last response
// submit.execute({ email, turnstileToken }) → fire the action

useAction wraps Astro’s action call in a Phaze signal.async-flavored object. Same pending/value/error API as signal.async, just keyed off an Astro action call instead of a fetch loader. The wrapper handles abort-on-rerun cleanup via abortSignal() the same way signal.async does.

This is an Astro-Actions-specific helper, so it lives here rather than in phaze core or phaze-compile. Other Astro adapters (Cloudflare’s @astrojs/cloudflare) compose with it transparently — the Actions API and useAction work whether the deployment target is Cloudflare Workers, Node, or static prerendering.

  • Astro reference page — the canonical install + setup guide.
  • SSR & hydration — what happens during SSR, how <astro-island> hydrates, the client:only vs client:load trade-off.
  • phaze-astro source on GitHub — small package (~200 lines including the actions wrapper), worth a read to see the full integration in one sitting.