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.
The navigation lifecycle
Section titled “The navigation lifecycle”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:
pushStates the new URL.- Fetches it with a
phaze-router: 1request header and reads the response as text. - Parses the HTML, extracts the new
#__phaze_root__subtree and thewindow.__PHAZE_CF__edge-signal payload, and updatesdocument.titlefrom the new head. - 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 swapWhat 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.
Two swap modes
Section titled “Two swap modes”How the swap applies depends on whether you defined a Phaze App (src/app.tsx):
| Phaze App present (recommended) | No Phaze App (direct-mount) | |
|---|---|---|
| Mechanism | currentRoute() signal updated via __setCurrentRoute(...) | root.innerHTML = newRoot.innerHTML + re-startClient() |
| Re-hydration | None — only the page subtree re-renders | Full — the whole scope tears down and re-hydrates |
| Chrome / module signals / focus across nav | Preserved (everything outside the page view) | Lost |
currentRoute() | returns RouteState | returns 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() and RouteState
Section titled “currentRoute() and RouteState”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).
Page Anatomy
Section titled “Page Anatomy”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.
The five exports
Section titled “The five exports”| Export | Type | Runs | What it controls |
|---|---|---|---|
default | ({ data }) => Node | client + server | The page component. Called with data = the loader’s return value. |
head | HeadFn | PageHead | server | <title>, description, raw <head> tags. Bare object for static; function form for per-request. |
loader | PageLoader | server | Page data — output flows into data prop and currentRoute().data. Async-friendly, full ctx. |
revalidate | number | RevalidateSpec | build-time → response header | Per-page caching: maxAge, swr, tags. See Caching. |
headers | HeadersFn | server | Raw response headers — escape hatch when revalidate isn’t enough (Link preload hints, custom X-…). |
layout | ({ children }) => Node | server + client | Section 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.
A page using everything
Section titled “A page using everything”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/revalidateresolve in parallel server-side, thendefaultrenders 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.
Layouts and slots
Section titled “Layouts and slots”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.
Named slots
Section titled “Named slots”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 theXprop; untagged children fall into the defaultchildren. Slots must be tagged on a<Fragment slot="X">— phaze only extracts Fragments, soslot="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.extractSlotsstrips it, lifting its children into the lazy() => …prop and emitting a plain array for multi-child slots (() => [a, b]), never a runtimeJSXFragment— so theFragmentsymbol never reachesphaze.- 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).
Section layouts — export const layout
Section titled “Section layouts — export const layout”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:
import AboutLayout from '../../components/AboutLayout'export const layout = AboutLayout // ({ children }) => Nodeexport 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
Section titled “Caching”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.
The four primitives
Section titled “The four primitives”| Primitive | Layer | Author site | What it caches | Cost |
|---|---|---|---|---|
prerender: ['/about'] | build | vite.config.ts plugin option | Whole 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.rules | edge | per-page export and/or plugin config | The 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 tab | reads the SSR response’s CDN-Cache-Control | The 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 subpath | A 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.
The router cache layer
Section titled “The router cache layer”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 behavior | Network fetch? | Worker invocation? |
|---|---|---|---|
0 – max-age | HIT — serve cached entry from memory | No | No |
max-age – max-age + swr | STALE-SWR — serve cached entry immediately, fire background refresh to refill | Yes (background, non-blocking) | Yes (background) |
past max-age + swr | MISS — fetch fresh, fill cache | Yes (blocking) | Yes (CDN may HIT) |
(no max-age directive) | Never cached — every nav fetches | Yes (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 } }:
- User soft-navs to
/products/123→ router fetches HTML → CDN response → router cache fills, CDN may also cache. - User soft-navs to
/, then back to/products/123within an hour → router cache HIT → no fetch, no CDN hit, no Worker invocation, no network. Page swaps from memory. - User soft-navs to
/products/456(different URL) → router cache MISS → fetch. - 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.
Three cache layers, one directive
Section titled “Three cache layers, one directive”“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:
| Layer | Where it lives | Honors which header | Saves a network GET? | Saves a Worker invocation? |
|---|---|---|---|---|
| Router cache | Browser tab in-memory (phaze-router) | CDN-Cache-Control (preferred) or Cache-Control | ✅ | ✅ |
| CDN edge cache (Cloudflare) | A Cloudflare PoP near the user | CDN-Cache-Control (preferred) or Cache-Control: s-maxage=N | ❌ — the GET reaches the edge | ✅ |
| Browser HTTP cache | The user’s private browser cache | Cache-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 invocationThe 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.hrefassignment). The router cache lives in JS memory; a full reload drops it. Browser HTTP cache (if enabled viacacheControl) 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
withRevalidateon asignal.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 renderedReading it:
- Left branch (HIT) — the cheapest path. Browser memory only; no network, no Worker. The
console.log HITis 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. revalidateTagpurges the CDN. Open tabs holding router-cache entries don’t get the message; they continue serving the cached entry until their own TTL expires.
Edge cache: maxAge + swr
Section titled “Edge cache: maxAge + swr”The headline caching surface. Either declare per-page or centrally in vite.config.ts:
// src/pages/products/index.tsx — per-pageexport const revalidate = { maxAge: 60, swr: 300, tags: ['products'] }// vite.config.ts — central, one source of truthcloudflare({ 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=300Cache-Tag: productsCDN-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).
The CDN’s decision table
Section titled “The CDN’s decision table”Given maxAge: 60, swr: 300, with t = seconds since the cache was last filled:
| t | What the CDN does | Worker runs? | Loader queries D1/KV? |
|---|---|---|---|
| 0 (first visit) | MISS — invoke Worker, cache the response, serve it | Yes | Yes |
| 0 – 60 s | HIT — serve the cached HTML | No | No |
| 60 – 360 s | STALE-but-within-SWR — serve the cached HTML immediately, fire a background request that re-runs the Worker, replace the cache when it returns | Yes (background) | Yes (background) |
| > 360 s | STALE-past-SWR — block the request, invoke Worker, cache, serve | Yes (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.
The request lifecycle
Section titled “The request lifecycle” ┌─────────────────────────────────────┐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 servedTags: 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:
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.
Dynamic per-route tags
Section titled “Dynamic per-route tags”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.
Per-page vs central rules
Section titled “Per-page vs central rules”Both views exist; both compose. Precedence is layered with force:
- Plugin
cache.rulesis the baseline — defaults by glob. Most-specific glob wins; ties to config order. - Per-page
revalidatelayers on top:- Timing fields (
maxAge,swr,cacheControl): page replaces config. - Tags: page extends config (union — the product detail page is also
products).
- Timing fields (
force: trueon 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.
cache: { rules: { '/products/*': { maxAge: 3600, tags: ['products'] }, // baseline '/admin/*': { force: true, cacheControl: 'no-store' }, // enforced },}export const revalidate = { tags: ['featured'] }// → effective: { maxAge: 3600, tags: ['products', 'featured'] }// (timing inherited; tags merged)
// src/pages/products/sale/index.tsxexport 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.tsxexport 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.
Naming: why maxAge and not sMaxAge
Section titled “Naming: why maxAge and not sMaxAge”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), overridesmax-agefor 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}Two completely different “SWR”s
Section titled “Two completely different “SWR”s”The name SWR appears in two unrelated places. Conflating them is a common stumble:
- SWR-the-HTTP-directive (
stale-while-revalidate=NinCDN-Cache-Control). What this section is about. CDN behavior, server-emitted, no JavaScript. Not a “client component” anything. - SWR-the-React-library (the
swrnpm package by Vercel). A client-side data-fetching hook (useSWR). In phaze, the equivalent pattern issignal.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.
Client-side data revalidation
Section titled “Client-side data revalidation”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 clientOnly 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.staleTimesinnext.config.js, independent of the server-siderevalidate. 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 changeddynamicfrom 30s to 0s) because the confusion was rampant in the community. Phaze uses the response’s ownCDN-Cache-Controldirective 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-Tagpurge already targets, andcurl -Ishows the same way as any other response. revalidateTagreaches everything coherently. Next’srevalidateTaginvalidates Next’s own incremental cache; the client Router Cache invalidates via separate hooks (router.refresh()etc.). Phaze’srevalidateTagpurges the CDN; the router cache’s lifetime is bounded by the sameCDN-Cache-Controldirective 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 withrevalidate: { 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-Controlfor 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’sCDN-Cache-Controldefault keeps the cache out of browsers (and in the CDN + router cache layers), sorevalidateTagpurges 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.
Caching pitfalls
Section titled “Caching pitfalls”prerender:+revalidateon 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,”revalidatecovers it withoutprerender:.maxAgewithoutswris fine but harsher. The visitor at t = maxAge + 1 s waits for a full Worker round trip. Almost always cheap to addswrat 5–10 ×maxAge.- Don’t cache mutations.
revalidatebelongs onGETpages and endpoints. Phaze actions arePOSTs — they invalidate the cache, they don’t get cached themselves. - A
'no-store'Cache-Controland aCache-Tagtogether 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.
| Axis | Astro | Next (App Router) | phaze-cloudflare |
|---|---|---|---|
| Routing | file-based src/pages/** | folder segments under app/**, [slug] | file-based src/pages/**, [id] / [...rest] |
| Layout definition | explicit — import <Layout>, wrap per page | convention — layout.tsx per folder | explicit — wrap in a Layout component |
| Layout nesting | manual (you wrap; forward via <slot name slot/>) | automatic by folder (root → section → page) | manual (compose components) |
| Persists across nav | no (MPA) unless <ClientRouter/> | layouts persist, don’t re-render | Phaze App persists — only the currentRoute() page subtree re-renders |
| Multiple holes | named slots (<slot name>), eager | children + parallel routes (@folder → named-slot props) | named slots (<Fragment slot>), lazy (() => thunks) |
| Page data | frontmatter / top-level await | async Server Component + params / searchParams | loader 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
Section titled “Prefetch”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 */}View Transitions
Section titled “View Transitions”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
rootonce — 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.
transition:NAME — naming an element
Section titled “transition:NAME — naming an element”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.
Wrap the component body in a Fragment
Section titled “Wrap the component body in a Fragment”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 componentexport 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. Thetransition: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.
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.