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 Signals
Section titled “Phaze Signals”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.
currentRouteis just another signal —effect,computed,watchall work on it directly; there’s no router-specific reactivity API to learn.
One signal in, computeds out
Section titled “One signal in, computeds out”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 state —
c(…)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.)
Quick start
Section titled “Quick start”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:
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:
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):
import type { PageHead, PageLoader } from '@madenowhere/phaze-cloudflare'
export const loader: PageLoader = async ({ env }) => env.DB.list() // → currentRoute().dataexport 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.
Enabling — the three flags
Section titled “Enabling — the three flags”| Flag | What it does | Cost (brotli) |
|---|---|---|
router: true | Hooks internal <a> clicks, fetches + swaps the page, drives history, animates with View Transitions. | ~400–500 B in the client entry |
prefetch: true | Warms in-viewport links (HTML + page chunk) so the click is near-instant. | ~350 B in the client entry |
speculationRules: true | Emits 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.
Bundle cost
Section titled “Bundle cost”| Feature | Flag | Cost (brotli) |
|---|---|---|
| Router + View Transitions | router: true | ~400–500 B in the client entry |
| Viewport prefetch | prefetch: true | ~350 B in the client entry |
| Speculation Rules | speculationRules: true | a 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.
See also
Section titled “See also”- Phaze + Cloudflare › Client-side router + view transitions — the parity comparison with Astro.
- Architecture › Signal-based router and Per-route dynamic imports — the architecture deep-dives + measured deltas.
- phaze-cloudflare tooling — the adapter overview.
In this section
Section titled “In this section”- 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
restrictedguard,+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