Skip to content

Phaze + Astro

Astro is built around the island architecture: ship mostly-static HTML, hydrate small interactive components in place. Phaze is built around fine-grained reactivity in a sub-3 KB runtime. The two assumptions line up: Astro wants the interactive bits to be small, Phaze keeps them small and removes the one trade-off island architecture usually asks you to accept — islands stuck inside their own world.

This page walks through the practical pieces of using them together. Each section is a usage step and a place the two frameworks reinforce each other.

Terminal window
pnpm add @madenowhere/phaze @madenowhere/phaze-astro astro
astro.config.mjs
import { defineConfig } from 'astro/config'
import phaze from '@madenowhere/phaze-astro'
export default defineConfig({
integrations: [phaze()],
})
tsconfig.json
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@madenowhere/phaze"
}
}

That’s it. The integration registers Phaze as an Astro renderer; Astro can now dispatch any client:* directive (or no directive at all) to a Phaze component. For the full list of supported directives and deeper integration internals, see the Astro reference page.

The runtime your visitor downloads to make an Astro island interactive is the smallest of any production-grade reactive framework — full runtime included:

Smallest interactive runtime

BundleBrotliRaw
Phaze — full runtime (JSX + reactivity + DOM diff)2.99 KB7.77 KB
Phaze — signals only (shared store, no JSX rendering)1.46 KB3.74 KB
Preact + @preact/signals~4.5 KB~13 KB
Vue 3 (compat build)~22 KB~70 KB
React + ReactDOM~44 KB~140 KB

For Astro that’s the difference between an island that fits inside one TCP slow-start window and one that doesn’t. Most visitors get an interactive button before the rest of the page is even painted.

The number isn’t won by leaving things out. The full runtime above includes:

  • signal, computed, effect, watch, cleanup
  • JSX runtime (jsx, jsxs, Fragment)
  • The DOM render path (render, hydrate)
  • All five JSX namespaces (on:, class:, bind:, phaze:, use:)
  • Auto-tracking, batching, glitch-free updates

Subpaths like phaze/store, phaze/portal, phaze/catch, phaze/match are opt-in — most are compile-time inlined for the common case (zero runtime bytes); the runtime fallback adds a few hundred bytes brotli per subpath only when needed. Not importing them costs nothing.

Astro’s whole proposition is that most of the page is static HTML and the JavaScript is the small part. If the JavaScript a single interactive button costs you is 50 KB, the island model is already compromised before you write your component. A 2.99 KB runtime makes the island model deliver what it advertises — a button is a button-sized download, not a framework-sized download.

It also changes the math on how many islands per page is reasonable. Each additional island shares the same already-loaded runtime; the per-island marginal cost is just the component code itself, often a few hundred bytes after tree-shaking. A page with twenty Phaze islands ships less interactive JS than a single React island would.

A Phaze component IS an Astro island. The only thing that makes it an island is mounting it from a .astro file with a client:* directive.

src/components/Counter.tsx
import { signal } from '@madenowhere/phaze'
export default function Counter({ start = 0 }: { start?: number }) {
const count = signal(start)
return (
<button on:click={() => count.set(count.current() + 1)}>
{count}
</button>
)
}
src/pages/index.astro
---
import Counter from '../components/Counter.tsx'
---
<h1>Hello</h1>
<Counter client:only="phaze" start={5} />
<Counter client:load start={0} />

The same component runs as a client-only island or a hydrated server-rendered island depending on the directive. No second variant to write.

Two islands on the same page can talk to each other through a signal in a shared file. That’s the entire feature.

src/store.ts
import { signal } from '@madenowhere/phaze'
export const selectedPost = signal<string>('landing')
// src/components/NavSelector.tsx — an island in the header
import { selectedPost } from '~/store'
export default () => (
<button on:click={() => selectedPost.set('post-2')}>
Open post 2
</button>
)
// src/components/PostViewer.tsx — a different island, lower on the page
import { selectedPost } from '~/store'
export default () => <article>Now showing: {selectedPost}</article>
src/pages/index.astro
---
import NavSelector from '../components/NavSelector.tsx'
import PostViewer from '../components/PostViewer.tsx'
---
<NavSelector client:only="phaze" />
<PostViewer client:load />

Click the button in the nav. The article below updates. No event bus, no postMessage, no library — the two islands are simply reading the same selectedPost.

When the page loads:

  1. The browser fetches each island’s JavaScript.
  2. Both islands’ code contains the line import { selectedPost } from '~/store'.
  3. The browser loads store.ts once. The signal<string>('landing') call runs once. The signal object exists once in memory.
  4. Both islands hold a reference to that one signal — the same way two functions can both hold a reference to the same array.
  5. When NavSelector calls selectedPost.set('post-2'), the signal notifies everything that read it. PostViewer’s binding to {selectedPost} updates. The DOM text changes.

The key sentence: the file is the shared place. A variable declared at the top of a regular module is shared by every file that imports it. Phaze signals are just JavaScript values that know who’s reading them, so when one island writes, the other island’s screen changes.

By convention, in a single file — src/store.ts is what we use, but the name doesn’t matter. Phaze doesn’t know about it; nothing in the framework looks for “the store”. It’s just a regular file.

The reason to put them all in one place is to make the line between local and shared state visible at a glance:

  • Inside a component function → local to that island. Disposed when the component disposes.
  • In src/store.ts (or any module-level file) → shared across every island that imports it. Lives for the page.

If you accidentally declared the same signal in two files, you’d get two separate signals — one per file. Centralizing in one file prevents that.

React islands have no React-level mechanism for sharing state across islands. Each client:only="react" island is its own React root — useContext / useState / useReducer are all scoped to that root.

To get the same behavior on React, see Astro’s own recipe — Sharing state between Astro islands. With Phaze it’s import { x } on both sides and you’re done.

Astro Actions are typed server endpoints you call from the client. Phaze’s useAction (from @madenowhere/phaze-astro/actions) makes Astro Actions Reactive.

src/components/SignupForm.tsx
import { actions } from 'astro:actions'
import { useAction } from '@madenowhere/phaze-astro/actions'
import { signal, computed } from '@madenowhere/phaze'
export default function SignupForm() {
const email = signal('')
const signup = useAction(actions.signupForEarlyAccess)
const errorText = computed(() => (signup.error() as Error | null)?.message ?? '')
const submit = async (e: Event) => {
e.preventDefault()
await signup.execute({ email: email() })
}
return (
<form on:submit={submit}>
<input bind:value={email} type="email" placeholder="you@example.com" />
<button disabled={signup.pending} type="submit">
{signup.pending() ? 'Sending…' : 'Get access'}
</button>
<p class:hidden={!errorText()}>{errorText}</p>
</form>
)
}

signup.pending, signup.error, signup.data are signals — read them like any other signal, bind them anywhere in JSX. The button stays disabled while the call is in flight, the error paragraph hides itself when errorText is empty (via class:hidden), and the next call clears the error automatically. No useEffect, no separate state-management story for “in-flight requests”.

The component you wrote in Authoring an island doesn’t change shape based on whether it’s running on the server or in the browser. Astro renders it to HTML at request time using Phaze’s server renderer; the browser then hydrates the same component in place when its client:* directive triggers.

---
import Counter from '../components/Counter.tsx'
---
{/* server-rendered HTML, then hydrates on page load */}
<Counter client:load start={5} />
{/* server-rendered HTML, no hydration — the button won't be clickable */}
<Counter start={5} />
{/* no SSR — mounts entirely in the browser */}
<Counter client:only="phaze" start={5} />

The same Counter function runs in all three cases. There’s no “server component vs. client component” split to learn, no 'use client' directive at the top of files, no separate API for non-interactive renders. The directive on the Astro side decides what happens to the HTML; the component itself doesn’t change.

For the SSR mechanics — virtual DOM, hydration matching, what happens when SSR output and client output diverge — see SSR & hydration.

Astro’s <ClientRouter /> swaps the DOM in place when you navigate between pages, instead of doing a full page load. Phaze’s Astro integration hooks astro:after-swap to dispose island scopes whose host element didn’t survive the swap — so signals, effects, and cleanup(...) callbacks tied to those islands all run their teardown cleanly.

You don’t write anything for this; it just works. Add <ClientRouter /> to your layout and navigate — islands on the old page tear down, islands on the new page mount, shared signals in store.ts survive the navigation (they live at the module level, not inside any island).

src/layouts/BaseLayout.astro
---
import { ClientRouter } from 'astro:transitions'
---
<html>
<head><ClientRouter /></head>
<body><slot /></body>
</html>

Islands marked with transition:persist survive the swap intact — their phaze scope is moved into the new page along with the DOM node, no remount, no signal loss.

Each section above is the same story from a different angle: Phaze fits in the spaces Astro’s island model leaves open.

  • Astro wants small interactive bits → Phaze ships sub-3 KB of runtime, which is the smallest non-trivial reactive runtime in the ecosystem.
  • Astro doesn’t connect islands → Phaze connects them through JavaScript module scope, no extra library required.
  • Astro wants you to pick where things run → Phaze runs the same component on the server and in the browser without a “client” / “server” split in your source.
  • Astro added typed server actions → Phaze wraps them in signals so they fit the same reactive model as every other piece of state.
  • Astro added view transitions → Phaze cleans up after itself when the DOM swaps.

The combined effect is that you write one mental model — module-scope signals, component-scope signals, JSX bindings that subscribe automatically — and Astro’s island boundary stops being a wall you have to route around.