Skip to content

Phaze Router

The Phaze Router is the signal-based router shipped by @madenowhere/phaze-cloudflare. It turns full-page loads into soft navigations: intercept an internal link, fetch the next page’s HTML, swap the page subtree (animated with View Transitions where supported), and update history — without a network round-trip’s worth of blank screen and, with a Phaze App, without re-running the whole page’s reactivity.

It is not part of phaze core, and it is not Astro’s <ClientRouter /> — it’s the native phaze-cloudflare router, built on the adapter’s whole-page SSR + single-hydrate model.

phaze-cloudflare renders the whole page as one component tree and hydrates it with a single hydrate() call — there are no islands. A “route” is just a page module (src/pages/**) whose default export is that tree.

Without the router, clicking a link is a normal browser navigation: new request, new document, fresh hydrate. The router replaces that with an in-page swap of #__phaze_root__ (the SSR’d page root). What makes it cheap is the Phaze App: if you define src/app.tsx, the Phaze App hydrates once at first load and thereafter only the page subtree re-renders — driven by a currentRoute() signal — so layout chrome, module-scope signals, focus, and in-flight animations all survive the navigation.

The route isn’t a tree to rebuild — it’s one signal. The router sets currentRoute(); the reactive graph does the rest. That’s where the wins come from:

  • No re-hydration on nav. Updating the route signal re-runs only the subtree that reads it (the page view). Everything outside — the Phaze App chrome — never re-mounts. Traditional routers either innerHTML-swap + re-hydrate the whole document or rebuild a VDOM and diff it; the signal model does neither.
  • No diff phase. There’s no virtual DOM and no reconciliation pass — the route signal notifies its subscribers and exactly one swap happens. The “router” is conceptually just a signal plus a fetch.
  • Browser-native state survives the navigation. Focused inputs mid-type, scroll position in persistent panels, in-flight CSS/Web Animations, <video> playback, <details> open state — anything outside the page view is untouched, because it was never torn down.
  • Module-scope signals persist across routes. App-wide state (auth, theme, cart) lives in module-level signals that outlive every navigation — no context re-provision, no re-fetch on each page.
  • Derived route state is memoised and fans out. c(currentRoute()?.params.id) re-derives only when the route actually changes, and one computation feeds every reader across the chrome. A tree-rebuilding router re-derives from scratch on every nav.
  • It composes with the rest of phaze. currentRoute is just another signal — effect, computed, watch all work on it directly; there’s no router-specific reactivity API to learn.

The whole router is one source signal with a graph of computed()s hanging off it — no VDOM, no reconciler, no keyed diffing:

  • currentRoute() — the source; the router sets it on each navigation.
  • the page view — a reactive child that reads currentRoute(), so only the page subtree re-renders.
  • derived route statec(…) off the route, memoised and fanned out to every reader.

Persistence and surgical updates fall out of computed() memoisation, not a reconciler — a value derived from the route only re-runs when that value actually changes. The headline example is a nav in the Phaze App whose active link updates on every navigation without the nav re-rendering at all:

// in the Phaze App (the persistent root — never re-mounts)
<nav>
<a href="/" class:active={currentRoute()?.pathname === '/'}>Home</a>
<a href="/about" class:active={currentRoute()?.pathname === '/about'}>About</a>
</nav>
{children} {/* the page view — swaps on nav */}

On / → /about, three things happen and nothing else: (1) the nav persists (it’s in the persistent root, never torn down), (2) the page swaps (the children view re-runs on currentRoute), and (3) the active-link class flips surgically — only the two class:active toggles run. Scroll position, a focused input, a sticky <video> — all untouched.

No c() is needed here: class:active={expr} already compiles to a reactive effect(() => el.classList.toggle('active', !!expr)), so the currentRoute() read inside is tracked automatically — zero extra bytes beyond the class: toggle itself. (c() earns its keep when you derive a value read by several bindings — one memoised computation fanning out — not for a single reactive attribute, which the class: / phaze: namespaces already wrap in an effect.)

Where other routers rebuild a route tree and keyed-reconcile which parts changed, phaze’s computed equality check is the reconciliation — the same mechanism that lets c(count() * 2) skip recompute when count is unchanged. The “router” is a signal plus a fetch; the signal graph decides what re-renders. (When section layouts land via export const layout, the layout persists by the very same rule: a computed off the route’s layout that only fires when the layout itself changes.)

Three pieces: turn the flag on, define a Phaze App, write page modules.

1. Enable the router (and the navigation-perf companions) in your Vite config:

vite.config.ts
import cloudflare from '@madenowhere/phaze-cloudflare/vite'
export default defineConfig({
plugins: [cloudflare({
pages: 'src/pages',
router: true, // client-side routing + View Transitions
prefetch: true, // warm in-viewport links (recommended with router)
speculationRules: true, // browser-native prerender/prefetch
})],
})

2. Define a Phaze App at src/app.tsx — this is what makes navigations skip re-hydration:

src/app.tsx
import { currentRoute } from '@madenowhere/phaze-cloudflare'
import Header from './components/Header'
export default function App({ children }: { children?: () => unknown } = {}) {
return (
<>
<Header /> {/* persistent chrome — survives every nav */}
{children ?? (() => { {/* the page view */}
const r = currentRoute()
if (!r) return null
const Page = r.module.default
return <Page data={r.data}/>
})}
</>
)
}

3. Write page modules under src/pages/ — a default-exported component, plus optional loader (server data) and head (per-route metadata):

src/pages/index.tsx
import type { PageHead, PageLoader } from '@madenowhere/phaze-cloudflare'
export const loader: PageLoader = async ({ env }) => env.DB.list() // → currentRoute().data
export const head: PageHead = { title: 'Home' }
export default function Home({ data }: { data: Item[] }) {
return <ul>{data.map(i => <li>{i.name}</li>)}</ul>
}

Now <a href="/about"> navigates client-side. That’s the whole surface.

FlagWhat it doesCost (brotli)
router: trueHooks internal <a> clicks, fetches + swaps the page, drives history, animates with View Transitions.~400–500 B in the client entry
prefetch: trueWarms in-viewport links (HTML + page chunk) so the click is near-instant.~350 B in the client entry
speculationRules: trueEmits a native Speculation Rules script so the browser prerenders/prefetches eligible navs itself.a few bytes of inline <script>

All three are independent and opt-in; all three live in the client entry chunk — phaze is never touched. prefetch and speculationRules are recommended alongside router to round out the navigation-perf story.

FeatureFlagCost (brotli)
Router + View Transitionsrouter: true~400–500 B in the client entry
Viewport prefetchprefetch: true~350 B in the client entry
Speculation RulesspeculationRules: truea few bytes of inline <script>

All opt-in, all in the client entry chunk — phaze is never touched. Per-route page chunks are code-split automatically: only the visited page’s chunk is fetched, and prefetch warms the next one before you click.

  • Navigation lifecycle — the soft-navigation lifecycle, the two swap modes, and currentRoute() / RouteState
  • Pages — page anatomy (the exports + PageContext), file-system conventions (reserved basenames incl. status-code pages, content endpoints, src/api/, route groups), sitemap, and i18n
  • Layouts and slots — named slots, section layouts, and @name/ parallel routes
  • Authorization — restricted routes (the restricted guard, +name/ folders, value forms, sessions) and the 401/403/404 outcomes
  • Caching — the router cache, edge cache, tags, and client-side data revalidation
  • View transitions & prefetch — animating the swap and warming in-viewport links
  • Compared to Astro and Next — the routing model side by side