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.

When router: true, the client entry calls startRouter(routes), which registers one delegated click listener on document plus a popstate listener. On a qualifying click it:

  1. pushStates the new URL.
  2. Fetches it with a phaze-router: 1 request header and reads the response as text.
  3. Parses the HTML, extracts the new #__phaze_root__ subtree and the window.__PHAZE_CF__ edge-signal payload, and updates document.title from the new head.
  4. Swaps the page — inside document.startViewTransition() where supported (Chrome 111+), or a plain swap otherwise.
click <a href="/about">
└─ pushState('/about')
└─ fetch('/about', { headers: { 'phaze-router': '1' } })
└─ parse → #__phaze_root__ + __PHAZE_CF__ + <title>
└─ startViewTransition(swap) // or plain swap

What gets intercepted. Only a plain left-click (button === 0, no Ctrl/Cmd/Shift/Alt) on a same-origin http(s) <a>. Modified clicks, middle-clicks, cross-origin links, and non-http protocols (mailto:, tel:) are left to the browser. Back/forward (popstate) navigates the same way.

The phaze-router header. The client sends phaze-router: 1 so a server can distinguish a soft navigation from a hard load (for logging, cache-keying, or future partial responses). Today the server returns the same full SSR’d HTML a hard navigation would — the router simply extracts the page root and payload from it.

Failure is always safe. A non-OK response, a fetch error, or a missing root/payload falls back to a full location.href navigation — a broken SPA hop never strands the user on a blank page.

How the swap applies depends on whether you defined a Phaze App (src/app.tsx):

Phaze App present (recommended)No Phaze App (direct-mount)
MechanismcurrentRoute() signal updated via __setCurrentRoute(...)root.innerHTML = newRoot.innerHTML + re-startClient()
Re-hydrationNone — only the page subtree re-rendersFull — the whole scope tears down and re-hydrates
Chrome / module signals / focus across navPreserved (everything outside the page view)Lost
currentRoute()returns RouteStatereturns null

The Phaze App path is the reason to define src/app.tsx: the Phaze App hydrated once at first load, and navigations only re-run the currentRoute()-driven page view. Persistent chrome (header, nav, theme provider), a focused search input in the header, an in-flight sidebar animation, a playing <video> in a sticky player — all survive, because nothing outside the page view re-mounts.

currentRoute() is exported from @madenowhere/phaze-cloudflare. It returns the active route, or null in direct-mount mode (no Phaze App to drive):

interface RouteState {
pathname: string
params: Record<string, string> // dynamic segments, e.g. /users/[id] → { id }
data: unknown // the page loader's return value
module: PageModule // the resolved page module (`.default` is the component)
}

It’s a signal: the router sets it on every navigation, so only the subtree that reads it re-runs. The canonical Phaze App reads it in the children ?? (...) fallback arrow (above).

Each file under src/pages/** is a route. A page module is just an ES module — every per-route behavior is a named export. The same shape applies whether the page is hard-loaded, soft-navigated by the router, prerendered, or SSR’d.

ExportTypeRunsWhat it controls
default({ data }) => Nodeclient + serverThe page component. Called with data = the loader’s return value.
headHeadFn | PageHeadserver<title>, description, raw <head> tags. Bare object for static; function form for per-request.
loaderPageLoaderserverPage data — output flows into data prop and currentRoute().data. Async-friendly, full ctx.
revalidatenumber | RevalidateSpecbuild-time → response headerPer-page caching: maxAge, swr, tags. See Caching.
headersHeadersFnserverRaw response headers — escape hatch when revalidate isn’t enough (Link preload hints, custom X-…).
layout({ children }) => Nodeserver + clientSection layout — wraps the page and persists across same-section navs. See Layouts and slots.

head, loader, and headers all run in parallel on the server — adding a loader doesn’t serialize with head, and per-page caching headers don’t add TTFB unless they themselves await I/O.

PageContext — what every server-side export receives

Section titled “PageContext — what every server-side export receives”
interface PageContext<Bindings> {
params: Record<string, string> // matched [id] / [...rest] segments
env: Bindings // typed CF bindings — DB, KV, R2, DO, …
request: Request // raw Request for header/body access
ctx: ExecutionContext // CF execution context — waitUntil, passThroughOnException
cookies: Cookies // per-request reader + writer (Set-Cookie queued)
}

Bindings is typed by your wrangler.toml via worker-configuration.d.ts (generated by wrangler types), so ctx.env.DB is autocompleted as your D1 binding.

src/pages/products/[id]/index.tsx
import type { PageLoader, HeadFn } from '@madenowhere/phaze-cloudflare'
import ProductLayout from '../../../components/ProductLayout'
export const layout = ProductLayout
export const head: HeadFn = ({ params, env }) =>
env.DB.prepare('SELECT name FROM products WHERE id = ?1')
.bind(params.id).first<{ name: string }>()
.then(row => ({ title: row?.name ?? 'Product' }))
export const loader: PageLoader = async ({ params, env }) =>
env.DB.prepare('SELECT * FROM products WHERE id = ?1')
.bind(params.id).first()
export const revalidate = {
maxAge: 3600,
swr: 86400,
tags: ({ params }) => [`product:${params.id}`], // dynamic per-route tag
}
export default function Product({ data }) {
return <article>{/* … */}</article>
}

Same module shape across deployment modes:

  • Hard load (cache miss): head, loader, headers/revalidate resolve in parallel server-side, then default renders into the SSR’d HTML.
  • Soft nav (router): the same HTML response is fetched as text; the client extracts the page subtree + __PHAZE_CF__ payload.
  • Prerender: the same exports run at build time, written to dist/client/<path>/index.html (with the limitations in Phaze + Cloudflare → Static prerendering).

Dynamic segments use [id] / [...rest] filenames; the matched values arrive as params on ctx and as currentRoute().params on the client.

The Phaze App (src/app.tsx) is the persistent root — it wraps every page and stays mounted across navigation. A Layout is an ordinary component the App (or a page) wraps content in for per-page chrome. Layouts use slots to expose more than one insertion point.

A named slot is just content the caller tags with a name (slot="header") so the component can drop it into a specific spot — as opposed to the default slot (children), which is the single untagged blob between the tags.

One component, three holes:

// CALLER fills the holes:
<Layout>
<Fragment slot="header"><Nav/></Fragment> {/* named slot "header" */}
<Fragment slot="footer"><Footer/></Fragment> {/* named slot "footer" */}
<PageContent/> {/* no slot= → the DEFAULT slot */}
</Layout>

phaze-compile (extractSlots) lifts each named slot to a top-level prop:

<Layout header={() => <Nav/>} footer={() => <Footer/>}>
{() => <PageContent/>} {/* the default child */}
</Layout>

So the Layout receives:

  • children<PageContent/> (the default slot)
  • header() => <Nav/> (the named slot “header”)
  • footer() => <Footer/> (the named slot “footer”)

…each a lazy Slot (() => JSXChild). The Layout reads them as ordinary props — type it inline so the slots are visible right in the signature:

import type { JSXChild } from '@madenowhere/phaze'
import type { Slot } from '@madenowhere/phaze-cloudflare'
function Layout({ children, header, footer }: {
children?: JSXChild
header?: Slot
footer?: Slot
}) {
return <>
{header} {/* "header" renders here */}
<main>{children}</main> {/* default slot renders here */}
{footer ?? <Footer/>} {/* "footer", or a fallback if not passed */}
</>
}

Reads are bare ({header}, not {header?.()}): the runtime renders a function child as a reactive dynamic child, so the slot honors the lazy contract and re-renders if its content reads signals.

The pieces:

  • The slot="X" attribute is the decider; <Fragment> is the carrier. slot="X" routes that content into the X prop; untagged children fall into the default children. Slots must be tagged on a <Fragment slot="X"> — phaze only extracts Fragments, so slot="X" on a plain element is not lifted (it stays an inline child). <Fragment> also groups multiple children under one slot name without a wrapper element.
  • <Fragment> is compile-only. extractSlots strips it, lifting its children into the lazy () => … prop and emitting a plain array for multi-child slots (() => [a, b]), never a runtime JSXFragment — so the Fragment symbol never reaches phaze.
  • Why bother — with only children (one default slot) a component has one place to inject caller content. Named slots let the caller fill multiple distinct holes and let the component control placement of each.

It’s the same idea as Astro’s <slot name="x"/> and Vue’s named slots, but phaze flattens slots to props: <Fragment slot="header"> becomes a header={() => …} prop — exactly the React shape <Layout header={…} footer={…}>{page}</Layout>, just with the <Fragment slot> authoring sugar and a lazy () => wrap (so a slot renders only if/when the component places it).

For per-section chrome, a page exports a layout; the router wraps the page in it and persists it across navigations within the same section:

src/pages/about/index.tsx
import AboutLayout from '../../components/AboutLayout'
export const layout = AboutLayout // ({ children }) => Node
export const head = { title: 'About' }
export default function About({ data }) { return <p></p> }

The persistence falls out of computed(): the client tracks c(currentRoute()?.module.layout), which only fires when the layout component reference changes. Navigating /about → /about/team (both export const layout = AboutLayout) keeps the layout mounted and swaps only the page inside it; navigating to a page with a different layout swaps the layout. SSR and prerender wrap the page in the layout in one pass.

A layout is an ordinary component (({ children }) => Node) and can use named slots like any other. It must return a real element — a fully-static, prop-less layout would hoist to a staticSubtree handle (give its root a binding, like any component). Persistence requires a Phaze App; without src/app.tsx the layout still applies but doesn’t persist (direct-mount mode). v1 is a single section layout per page; nested chains (root → section → sub-section) compose explicitly.

Caching in phaze-cloudflare is three small primitives that compose. They live at different layers, do different work, and the only coordination point between them is the tag — a string label that lets you invalidate many cached things at once without enumerating their URLs.

PrimitiveLayerAuthor siteWhat it cachesCost
prerender: ['/about']buildvite.config.ts plugin optionWhole pages → static HTML at build time, written to dist/client/<path>/index.html and added to _routes.json exclude. The Worker is never invoked for that path.0 B added to client or worker
export const revalidate = … + cache.rulesedgeper-page export and/or plugin configThe SSR’d response at Cloudflare’s edge cache. Worker only runs on cache miss / SWR refresh.0 B runtime — just response headers
Router cache (automatic when router: true)client tabreads the SSR response’s CDN-Cache-ControlThe fetched page’s HTML subtree + __PHAZE_CF__ payload + title, in-memory by URL. Soft navs to a still-fresh route skip the fetch entirely.~400 B brotli in the router chunk
withRevalidate(asyncSig, sec)client tab@madenowhere/phaze/revalidate subpathA signal.async’s loaded value, refreshed periodically in the browser. Only the JSX nodes that read the signal re-render.~80 B per consumer chunk

They compose. The CDN cache and the router cache are driven by the same directive (CDN-Cache-Control on the SSR response) — set revalidate: { maxAge: 60 } once and both layers honor it: the CDN serves up to 60 s of visitors without invoking the Worker, and within the same browser tab subsequent soft navs to that route skip even the network round-trip.

When router: true is enabled, the router maintains an in-memory cache of fetched route responses keyed by URL. After a successful soft nav, the response’s CDN-Cache-Control (or Cache-Control) directives drive its lifetime in the cache:

t (since cache fill)Router behaviorNetwork fetch?Worker invocation?
0 – max-ageHIT — serve cached entry from memoryNoNo
max-agemax-age + swrSTALE-SWR — serve cached entry immediately, fire background refresh to refillYes (background, non-blocking)Yes (background)
past max-age + swrMISS — fetch fresh, fill cacheYes (blocking)Yes (CDN may HIT)
(no max-age directive)Never cached — every nav fetchesYes (every time)Yes (CDN may HIT)

Hard-capped LRU at 20 entries (older entries evicted on overflow). Cache is per-tab and per-session — lost on full reload, like a real CDN PoP rotating an isolate.

So with cache.rules: { '/products/*': { maxAge: 3600 } }:

  1. User soft-navs to /products/123 → router fetches HTML → CDN response → router cache fills, CDN may also cache.
  2. User soft-navs to /, then back to /products/123 within an hour → router cache HIT → no fetch, no CDN hit, no Worker invocation, no network. Page swaps from memory.
  3. User soft-navs to /products/456 (different URL) → router cache MISS → fetch.
  4. After an hour but within max-age + swr → STALE-SWR: instant swap from cache + background refresh updates the entry.

Invalidation: revalidateTag(ctx, 'tag') from an action purges the CDN cache for matching URLs. It does not reach in-flight browser tabs holding a stale router-cache entry — the cache layer is unaddressable from the server. Pick max-age values that match how fresh the data needs to be (short for actively-mutated content, long for static); for actively-mutated routes, prefer short max-age + generous swr so the next-visitor experience stays fast.

Dev observability: in pnpm dev:hmr, the router fires a phaze:router-cache CustomEvent on the document on every HIT / STALE-SWR (consumer code can listen if needed), and prints a [phaze:router] HIT /about (served from cache) line to the browser DevTools console (dev-only; production-stripped). The dev request log on the server side naturally shows no log line when a router HIT skipped the fetch — the absence is the signal that the cache worked.

“Route caching” isn’t one thing — for a single page response there are three different layers that can serve it on the way to the user, each honoring a Cache-Control-family directive. The router cache is just one of them; understanding all three is the picture:

LayerWhere it livesHonors which headerSaves a network GET?Saves a Worker invocation?
Router cacheBrowser tab in-memory (phaze-router)CDN-Cache-Control (preferred) or Cache-Control
CDN edge cache (Cloudflare)A Cloudflare PoP near the userCDN-Cache-Control (preferred) or Cache-Control: s-maxage=N❌ — the GET reaches the edge
Browser HTTP cacheThe user’s private browser cacheCache-Control: max-age=N (NOT CDN-Cache-Control — browsers ignore it)

Per-page revalidate: { maxAge: N } emits CDN-Cache-Control: max-age=N — the CDN edge and router cache layers both honor it. The browser HTTP cache is intentionally bypassed by this directive (it doesn’t read CDN-Cache-Control). To engage the browser cache too, opt in via the escape hatch:

export const revalidate = {
maxAge: 60, // → CDN-Cache-Control: max-age=60 (CDN + router)
cacheControl: 'public, max-age=5', // → Cache-Control: public, max-age=5 (browser)
}

Why phaze deliberately keeps the browser cache off by default: revalidateTag from an action can purge the CDN cache; the browser’s private cache can’t be reached from the server. A long browser-side max-age means a user holding the page in a tab/back-button stack sees a stale version regardless of any server-side invalidation. So phaze uses CDN-Cache-Control for the default maxAge (CDN + router only) and reserves browser caching for explicit opt-in.

The directive cascade. One revalidate: { maxAge: 60 } declaration produces:

revalidate: { maxAge: 60 }
CDN-Cache-Control: max-age=60
├─► Router cache (in-tab): serves cached entries for 60s, no fetch
└─► CDN edge cache: serves cached entries for 60s, no Worker invocation

The router cache layer sits above the CDN: a router HIT means the request never leaves the browser at all. A router MISS hits the network; the CDN may then HIT and serve cached without the Worker.

What the router cache does NOT cover:

  • Hard navigations (typed URL, full page reload, location.href assignment). The router cache lives in JS memory; a full reload drops it. Browser HTTP cache (if enabled via cacheControl) does cover this case.
  • Cross-tab sharing. Each tab has its own router cache. A second tab making the same nav re-fetches even if the first tab has it cached. CDN edge cache does cover this.
  • Cross-session persistence. Closing the tab drops the cache. Browser HTTP cache + CDN edge cache cover this.
  • Data refresh during a long-lived page view. The router cache covers navigation between pages; for a value that should refresh while the user stays on one page, use withRevalidate on a signal.async.

The navigation flow through the cache layers

Section titled “The navigation flow through the cache layers”

The diagram that’s hardest to keep in your head — what actually happens between a click and a rendered new page, and which cache layer can short-circuit the work:

USER CLICKS INTERNAL <a>
┌─────────────────────────────┐
│ phaze-router.navigate(url) │
│ • cacheKey = path + search │
└──────────────┬──────────────┘
LOOKUP in router cache
(in-memory Map per tab)
┌──────────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌────────┐
│ HIT │ │STALE-SWR │ │ MISS │
│age ≤ MA│ │MA < age │ │no entry│
│ │ │≤ MA + SWR│ │or past │
│ │ │ │ │MA+SWR │
└───┬────┘ └────┬─────┘ └───┬────┘
│ │ │
▼ ▼ │
swap from cache swap from cache │
console.log HIT + background fetch ──────────────────┤
(NO FETCH) console.log STALE │
│ │
│ ▼
│ FETCH(url, phaze-router: 1)
│ │
│ ▼
│ ┌──────────────────────────────────────┐
│ │ PROD: Cloudflare edge cache │
│ │ • HIT → serve from edge │
│ │ • STALE-SWR→ edge serves + back-fill│
│ │ • MISS → Worker invokes │
│ │ DEV (vite middleware): │
│ │ • logical simulator labels response │
│ │ • Worker SSRs every time │
│ │ • [phaze:dev] HIT|MISS|STALE-SWR │
│ └──────────────────┬───────────────────┘
│ │
│ ▼
│ parse #__phaze_root__
│ + __PHAZE_CF__ payload
│ + title
│ │
│ ▼
│ fill router cache
│ (if response has max-age)
│ │
│ ▼
│ apply swap + view-transition
│ │
└───────────────────────────────────────────────────┘
new DOM rendered

Reading it:

  • Left branch (HIT) — the cheapest path. Browser memory only; no network, no Worker. The console.log HIT is the dev-time signal; the Vite terminal stays silent because the request never left the browser.
  • Middle branch (STALE-SWR) — best-of-both-worlds path. The user sees the page instantly (memory swap), and a background fetch refreshes the entry for the next nav. The terminal logs the background fetch under the same URL.
  • Right branch (MISS) — the full round-trip. Fetch goes out; in production a CDN cache may HIT on it (saves Worker invocation but not the network), or MISS through to a fresh Worker invocation. In dev, the Vite middleware logs what state a real CDN would have served, then SSRs anyway. The response fills the router cache so the next nav to this URL becomes a HIT.
  • STALE-SWR feeds back into MISS for the actual fetch — the background refresh uses the same fetch + parse + fill code path; it just doesn’t apply a swap.

What’s not in the diagram but worth noting:

  • Hard navs (typed URL, Cmd-R, location.href) skip the router entirely. Goes straight to the FETCH box (browser ↔ CDN ↔ Worker). The router cache is bypassed because the browser tears the JS context down on a hard nav.
  • Prefetched routes (when prefetch: true) populate the router cache on viewport-entry, so the FIRST click on a prefetched link is already a HIT.
  • revalidateTag purges the CDN. Open tabs holding router-cache entries don’t get the message; they continue serving the cached entry until their own TTL expires.

The headline caching surface. Either declare per-page or centrally in vite.config.ts:

// src/pages/products/index.tsx — per-page
export const revalidate = { maxAge: 60, swr: 300, tags: ['products'] }
// vite.config.ts — central, one source of truth
cloudflare({
cache: {
rules: {
'/products/*': { maxAge: 3600, swr: 86400, tags: ['products'] },
'/api/*': { maxAge: 600, swr: 60 },
'/admin/*': { force: true, cacheControl: 'no-store, private' },
},
},
})

The plugin compiles the rules at config-resolve time and the worker merges them with each page’s revalidate export at request time — no source transform, no per-page headers emission. The wire output is a plain CDN-Cache-Control response header (plus Cache-Tag when tags are declared, and Cache-Control when the cacheControl escape hatch is used):

CDN-Cache-Control: max-age=60, stale-while-revalidate=300
Cache-Tag: products

CDN-Cache-Control is the modern, CDN-only header — Cloudflare honors it, browsers ignore it. So maxAge here means “cache at the edge for N seconds”, with no risk of a user’s browser holding the page in its private cache (where revalidateTag can’t reach it).

Given maxAge: 60, swr: 300, with t = seconds since the cache was last filled:

tWhat the CDN doesWorker runs?Loader queries D1/KV?
0 (first visit)MISS — invoke Worker, cache the response, serve itYesYes
0 – 60 sHIT — serve the cached HTMLNoNo
60 – 360 sSTALE-but-within-SWR — serve the cached HTML immediately, fire a background request that re-runs the Worker, replace the cache when it returnsYes (background)Yes (background)
> 360 sSTALE-past-SWR — block the request, invoke Worker, cache, serveYes (blocking)Yes (blocking)

The swr window is what makes a cache hit feel instant even when the data is older than maxAge. The 61-second visitor sees the page in 5 ms (stale) and the background refresh updates the cache for the next visitor. Without swr, that visitor pays the full Worker round trip.

┌─────────────────────────────────────┐
USER ── GET /products ─│ CLOUDFLARE EDGE CACHE │
│ reads CDN-Cache-Control, Cache-Tag │
│ decides: HIT / STALE-SWR / MISS │
└──────┬───────────────┬───────────────┘
│ │
HIT │ │ MISS / SWR background refresh
│ ▼
│ ┌─────────────────────────────┐
│ │ CLOUDFLARE WORKER │
│ │ (your phaze app) │
│ │ │
│ │ loader({ env }) { │
│ │ env.DB.select(...) │ ← bindings live here
│ │ } │
│ │ → SSR'd HTML + │
│ │ Cache-Tag: products │
│ └──────────┬───────────────────┘
│ │
▼ ▼
served to user cached, response also served

Tags: revalidateTag and on-demand invalidation

Section titled “Tags: revalidateTag and on-demand invalidation”

A tag is a label you stick on cached responses so you can invalidate many of them at once without knowing their URLs.

Imagine a store with five routes that all read the products table: /products, /products/123, /products/featured, /api/products, /admin/products. All five tag their cached responses products (via the plugin config rule or a per-page revalidate). When a mutation lands, one call invalidates all five:

src/actions/index.ts
import { defineAction } from '@madenowhere/phaze-cloudflare/actions'
import { revalidateTag } from '@madenowhere/phaze-cloudflare'
export const createProduct = defineAction({
handler: async (input, ctx) => {
await ctx.env.DB.insert('products', input)
await revalidateTag(ctx, 'products') // every cached entry tagged 'products' → evicted
return { ok: true }
},
})
export const updateProduct = defineAction({
handler: async ({ id, ...rest }, ctx) => {
await ctx.env.DB.update('products', id, rest)
await revalidateTag(ctx, `product:${id}`) // surgical: only this product's pages
return { ok: true }
},
})

The writer (createProduct) never had to enumerate which pages care about products. The five pages declared their interest with a tag; the action declared the event with a name. The string 'products' is the entire contract.

USER ── POST /_phaze/action/createProduct ──> WORKER ── env.DB.insert(...)
└─ revalidateTag(ctx, 'products')
┌────────────────────────────────┐
│ CF Cache Purge │
│ Every entry tagged 'products' │
│ → evicted from the edge cache │
└────────────────────────────────┘
Next GET /products misses cache,
Worker re-runs the loader,
new product appears.

Tags are global. revalidateTag('products') purges every cached entry with that tag — across pages, endpoints, layouts, anywhere. That global addressability is exactly the point: the writer doesn’t need to know the readers.

For a [id] page, the tag value depends on the params. Use the function form:

export const revalidate = {
tags: ({ params }) => [`product:${params.id}`],
}

updateProduct({ id: 123, … }) then calls revalidateTag(ctx, 'product:123') and purges only that product’s detail page, while a bulk re-categorisation that calls revalidateTag(ctx, 'products') purges all of them.

Both views exist; both compose. Precedence is layered with force:

  1. Plugin cache.rules is the baseline — defaults by glob. Most-specific glob wins; ties to config order.
  2. Per-page revalidate layers on top:
    • Timing fields (maxAge, swr, cacheControl): page replaces config.
    • Tags: page extends config (union — the product detail page is also products).
  3. force: true on a config rule flips it to enforcement: the page-level export is ignored, and the worker logs a one-time warning at cold start so silent overrides surface in the logs.
vite.config.ts
cache: {
rules: {
'/products/*': { maxAge: 3600, tags: ['products'] }, // baseline
'/admin/*': { force: true, cacheControl: 'no-store' }, // enforced
},
}
src/pages/products/featured/index.tsx
export const revalidate = { tags: ['featured'] }
// → effective: { maxAge: 3600, tags: ['products', 'featured'] }
// (timing inherited; tags merged)
// src/pages/products/sale/index.tsx
export const revalidate = 60 // shorthand for { maxAge: 60 }
// → effective: { maxAge: 60, tags: ['products'] }
// (page won on timing, config's tag carried over)
// src/pages/admin/users/index.tsx
export const revalidate = 300 // ignored — config has `force: true`
// → effective: { cacheControl: 'no-store' }
// cold-start warning in `wrangler tail`:
// [phaze-cloudflare] revalidate at /admin/users ignored — the cache.rules entry '/admin/*' is force: true.

The SRE view (open vite.config.ts, see every route’s policy at a glance) and the developer view (open a page, the rule sits next to the data it caches) both survive. Rule compilation happens once at config-resolve; the per-request merge is a cheap object lookup + 2-3 header sets — runtime cost on par with hand-writing the same headers export.

max-age and s-maxage are both real Cache-Control directives, but they scope different caches:

  • max-age=N → applies to all caches (browser and CDN).
  • s-maxage=N → applies to shared caches only (CDN/proxy), overrides max-age for those.

If a public page sets max-age=60, every user’s browser caches it locally — and revalidateTag can’t reach a private browser cache. That’s the trap.

Phaze sidesteps both by emitting the CDN-Cache-Control header instead of Cache-Control. CDN-Cache-Control is a CDN-only header (browsers ignore it entirely), so maxAge inside it cleanly means “max age at the edge” — same scope as s-maxage on the regular Cache-Control, just with the friendlier name.

When you actually want a browser cache, the cacheControl escape hatch lets you write the raw header:

export const revalidate = {
maxAge: 60, // → CDN-Cache-Control: max-age=60
cacheControl: 'public, max-age=5', // → Cache-Control: public, max-age=5
}

The name SWR appears in two unrelated places. Conflating them is a common stumble:

  • SWR-the-HTTP-directive (stale-while-revalidate=N in CDN-Cache-Control). What this section is about. CDN behavior, server-emitted, no JavaScript. Not a “client component” anything.
  • SWR-the-React-library (the swr npm package by Vercel). A client-side data-fetching hook (useSWR). In phaze, the equivalent pattern is signal.async(loader) + withRevalidate(sig, N) from @madenowhere/phaze/revalidate. Same idea — refresh data in the browser without a navigation — but signal-shaped.

When this doc writes swr: 300, it always means the HTTP directive. The client-side data revalidation pattern is described under client-side data revalidation below.

Edge caching keeps the page response fresh on the server side. Sometimes you want a specific value in the page to refresh without reloading anything — e.g. a live order count in the header that updates every 30 s while the user stays on the same page. That’s withRevalidate:

import { signal } from '@madenowhere/phaze'
import { withRevalidate } from '@madenowhere/phaze/revalidate'
const orderCount = signal.async(() => fetch('/api/orders/count').then(r => r.json()))
withRevalidate(orderCount, 30) // refresh every 30 s on the client

Only the JSX nodes that read orderCount() re-render when the signal updates — the surrounding tree doesn’t. SSR pre-fills the initial value (the worker awaits the loader during SSR), so the first paint already has data; the client revalidates from there. The effect re-arms its setTimeout on each pending → false transition, not via setInterval, so a slow loader can’t compound overlapping requests.

Compared to Next App Router & Astro ClientRouter

Section titled “Compared to Next App Router & Astro ClientRouter”

The router cache + CDN-driven directive design has concrete wins over the comparable surfaces in the two most-cited prior-art frameworks.

vs Next App Router (staleTimes):

  • One directive drives every layer. Next’s client-side Router Cache is configured separately, via experimental.staleTimes in next.config.js, independent of the server-side revalidate. Two independent knobs for the same conceptual question (“how long is this route fresh?”) — and Vercel walked back the default twice in two major versions (Next 14 → 15 changed dynamic from 30s to 0s) because the confusion was rampant in the community. Phaze uses the response’s own CDN-Cache-Control directive to drive both the CDN cache and the router cache; one knob, no drift.
  • No RSC protocol on the wire. Next sends an RSC payload (a custom serialization format) for soft navs. Phaze sends the same HTML the CDN already knows how to cache — plain text the browser’s developer tools can read, the CDN’s Cache-Tag purge already targets, and curl -I shows the same way as any other response.
  • revalidateTag reaches everything coherently. Next’s revalidateTag invalidates Next’s own incremental cache; the client Router Cache invalidates via separate hooks (router.refresh() etc.). Phaze’s revalidateTag purges the CDN; the router cache’s lifetime is bounded by the same CDN-Cache-Control directive so it ages out in lockstep. (Client tabs still hold stale entries until their TTL expires — same on both frameworks; no router cache can be remotely invalidated.)

vs Astro <ClientRouter />:

  • Astro doesn’t maintain a JS-level route cache at all. Every soft nav re-fetches, every time. Phaze’s router cache means a stable route can be served zero-fetch within a tab for the configured max-age. For a content site with revalidate: { maxAge: 3600 } on the marketing pages, every cross-page nav within an hour is a memory swap.
  • No server-side cache invalidation primitive. Astro relies on browser HTTP Cache-Control for soft-nav caching; that’s the user’s private cache, and you can’t purge it from the server. A CMS publish can’t invalidate cached pages already sitting in users’ browsers. Phaze’s CDN-Cache-Control default keeps the cache out of browsers (and in the CDN + router cache layers), so revalidateTag purges the CDN and the next nav fetches fresh.
  • Astro’s caching story is “use the browser’s HTTP cache and prefetch.” Phaze’s is “one directive cascades through three coordinated layers, all of which are invalidatable from a server action.” Same browser perf at the user end; vastly better cache coherence story for the operator.

In one line: phaze treats route caching as a stacked architecture driven by a single declaration; Next splits the layers into separate config surfaces, and Astro ships only the bottom (browser) layer.

  • prerender: + revalidate on the same path is a build error. The two are different operational modes (static-at-deploy vs cached-at-edge), and conflating them silently leads to mystery behavior. Drop one or the other; if you want “static HTML that periodically refreshes,” revalidate covers it without prerender:.
  • maxAge without swr is fine but harsher. The visitor at t = maxAge + 1 s waits for a full Worker round trip. Almost always cheap to add swr at 5–10 × maxAge.
  • Don’t cache mutations. revalidate belongs on GET pages and endpoints. Phaze actions are POSTs — they invalidate the cache, they don’t get cached themselves.
  • A 'no-store' Cache-Control and a Cache-Tag together is wasted bytes. If a route is uncacheable, don’t tag it; the CDN never had anything to invalidate.

Routing model — compared to Astro and Next

Section titled “Routing model — compared to Astro and Next”

phaze-cloudflare’s structure is a deliberate hybrid: file-based routing like both, an explicit Layout component + named slots like Astro, and a single persistent root (the Phaze App + currentRoute() signal) that delivers Next’s “layout persists across navigation” win without Next’s folder-nesting machinery.

AxisAstroNext (App Router)phaze-cloudflare
Routingfile-based src/pages/**folder segments under app/**, [slug]file-based src/pages/**, [id] / [...rest]
Layout definitionexplicit — import <Layout>, wrap per pageconventionlayout.tsx per folderexplicit — wrap in a Layout component
Layout nestingmanual (you wrap; forward via <slot name slot/>)automatic by folder (root → section → page)manual (compose components)
Persists across navno (MPA) unless <ClientRouter/>layouts persist, don’t re-renderPhaze App persists — only the currentRoute() page subtree re-renders
Multiple holesnamed slots (<slot name>), eagerchildren + parallel routes (@folder → named-slot props)named slots (<Fragment slot>), lazy (() => thunks)
Page datafrontmatter / top-level awaitasync Server Component + params / searchParamsloader export → data prop / currentRoute().data

The throughline: Astro = explicit composition, no magic; Next = convention-driven nesting + persistence. phaze takes Astro’s explicit, no-magic composition (layouts are just components; slots are named children) and gets Next’s persistence for free from the signal router — without per-folder layout.tsx conventions. The one feature it doesn’t auto-provide is Next’s per-segment nested layouts; you compose those explicitly.

prefetch: true runs an IntersectionObserver that warms internal links as they enter the viewport — fetching the HTML response and the page’s code-split chunk (via <link rel="modulepreload"> in the prefetched head). By click time the response is in the browser cache and the navigation resolves near-instantly. Opt a link out with data-no-prefetch:

<a href="/about">About</a> {/* prefetched on viewport entry */}
<a href="/huge" data-no-prefetch>Huge</a> {/* skipped */}

The swap runs inside document.startViewTransition() when the browser supports it, animating between the old and new DOM snapshots. Browsers without the API get an instant plain swap — the navigation still works, just without the animation.

What animates is decided by CSS, per the View Transitions pseudo-element tree: everything unnamed cross-fades as the root group; an element with its own view-transition-name becomes its own group. So the model is off vs opt-in:

  • Suppress the default. Hold the unnamed majority still by suppressing root once — then the swap is instant unless you opt an element in:
    ::view-transition-old(root) { display: none; } /* drop the old snapshot — no flicker */
    ::view-transition-new(root) { animation: 0s; } /* don't animate the new */
  • Opt an element in by naming it. Naming alone gives it the browser’s default cross-fade (a named group animates by default); the CSS ::view-transition-old/new(NAME) rules then customise — or suppress — that animation.

Rather than hand-write view-transition-name (a verbose Tailwind arbitrary class or an inline style), use the transition:NAME directive. phaze-compile rewrites it to the CSS property at build time. The canonical use is at the component level — naming the node a component returns, with zero boilerplate inside the component:

<Hero transition:graviton-hero/>

phaze-compile emits a post-creation op that sets view-transition-name on the node the component returns — with a Fragment-safe fallback so the same emit handles both component shapes:

// what phaze-compile emits (single expression, fits the IIFE sequence):
((t) => t && t.setProperty('view-transition-name', 'graviton-hero'))(
__el.style || (__el.firstElementChild && __el.firstElementChild.style)
)

Hero itself stays a plain export default function Hero() { … } — no viewTransitionName prop, no style threading, no ref.

The Phaze convention is to return the component body wrapped in a Fragment (<>…</>). The transition: directive is designed around this shape:

// src/components/Hero.tsx — canonical Phaze component
export default function Hero() {
return (
<>
<h1>Predict the peak…</h1>
<div use:parallax={} />
</>
)
}

At runtime, a Fragment-returning component evaluates to a DocumentFragment — which has no .style. The defensive emit above falls through to the first element child (the <h1> here), and the name lands there. That’s the natural “named root” of a multi-root component: the headline element is what the user sees first; ::view-transition-old/new(graviton-hero) choreographs the visual entry/exit at that element.

An Element-returning component (single root, no Fragment) also works — __el.style is defined → the name lands directly on the wrapping element, naming the whole component as one group. Pick by intent: Fragment for “name the first visible element,” single root for “name the whole component as a group.”

Why Fragment is the convention: Phaze components are universal (SSR + client) and lean toward Fragment wrapping so the parent can compose the chrome — there’s no implicit wrapping <div> to thread props through, no extra DOM nesting in the SSR’d HTML. The transition: directive is built to work with that shape unchanged.

Choreograph the animation in CSS — ::view-transition-old(NAME) for the exit, ::view-transition-new(NAME) for the enter, ::view-transition-group(NAME) for the position/size morph between the two pages:

::view-transition-old(graviton-hero) { animation: fade-out 0.2s ease-out both; }
::view-transition-new(graviton-hero) { animation: fade-in 0.2s ease-in both; }

It also works on a host element — <h1 transition:hero>Predict the peak</h1> merges style={{ viewTransitionName: 'hero' }} at compile time (zero runtime, SSR-serialised into the HTML). Useful for one-off naming inside a page module without lifting the element into its own component.

There’s deliberately no transition:persist or transition:animate (the compiler errors on both with a hint): persistence is structural — put the element in the Phaze App so it’s never torn down — and the animation lives in CSS, not on the element. See the DSL reference for the precise post-op shape on components.

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.