Skip to content

Cloudflare: Parity gaps & extras

FeatureAstrophaze-cloudflare
Named slots (<Fragment slot="X">)✓ — landed, with lazy () => wrap
Static prerender (per-route)export const prerender = true✓ — landed via cloudflare({ prerender: ['/', '/about'] }). Renders at build time, writes to dist/client/<path>/index.html, adds each path to _routes.json exclude so the Worker never sees it. 0 bytes added to client or worker bundles.
Image / Image service✓ — sharp at build time, variant files in dist/✓ — <Image> from /image subpath. Emits a CF Image Transformations srcset (/cdn-cgi/image/width=W,format=auto/...) at the edge. Zero build-time image deps; format negotiation happens per-request from the Accept header. ~500 B brotli per consuming chunk. Different trade-off (CF-locked vs portable); see below for details.
Content Collections✓ — built-in getCollection / getEntry + glob loader✓ — phaze:content with the same shape. newCollection({ pattern, schema }) in src/content.config.ts; getCollection / getEntry from phaze:content. Tiny YAML subset parser (~50 LOC, inline). No markdown renderer in the box — pipe body through your library of choice. Server-side only; client bundle ships a stub. Pairs naturally with prerender: for zero-runtime-cost blog pages.
MDX@astrojs/mdxnot yet
i18ni18n: { defaultLocale, locales }✓ — cloudflare({ i18n }) + phaze:i18n helpers (getLocaleUrl / currentLocale / getLocaleUrlList / preferredLocale); folder-based locale routes; automatic sitemap hreflang. preferredLocale is a built-in Accept-Language negotiator (no library). Auto-redirect (detect) pending.
Sitemap@astrojs/sitemap✓ — cloudflare({ site, sitemap }) auto-discovers static routes → dist/client/sitemap.xml (Worker-excluded); exclude globs, changefreq / priority / lastmod, customPages, i18n hreflang, serialize. Dynamic routes enumerate via a per-page sitemap loader run with real bindings at build (Method C) — past Astro’s static-only crawl. The sitemap.xml.ts endpoint still works for full code-first control.

The remaining user-facing gap is MDX (Astro pipes @astrojs/mdx; phaze-cloudflare has .md content collections but no JSX-in-markdown renderer). i18n and Sitemap have closed: cloudflare({ i18n }) adds folder-based locale routes + phaze:i18n helpers (getLocaleUrl / currentLocale / preferredLocale — the last a no-library Accept-Language negotiator), and cloudflare({ site, sitemap }) auto-discovers a sitemap from the route table, with dynamic routes enumerated by a per-page sitemap loader (real bindings at build, Method C) and automatic hreflang when i18n is set (the src/pages/sitemap.xml.ts endpoint still works for full code-first control). Hover/tap prefetch strategies remain a known small gap useful for nav menus.

import { Image } from '@madenowhere/phaze-cloudflare/image'
<Image
src="/hero.png"
alt="Hero banner"
width={1200}
height={600}
widths={[400, 800, 1600, 2400]}
sizes="(max-width: 768px) 100vw, 50vw"
priority
/>

Renders a plain <img> with loading="lazy" (eager + fetchpriority="high" when priority), decoding="async", explicit width/height (CLS-safe), and — when widths is set — a srcset of /cdn-cgi/image/width=W,format=auto/<src> URLs that Cloudflare’s edge transforms on-demand. AVIF / WebP / JPEG negotiation happens per-request from the Accept header.

Trade-off vs Astro’s <Image>: Astro processes images at build time with sharp (portable, adds ~30 MB of native deps, slower first build, static cacheable output). phaze-cloudflare delegates to CF’s edge transformation (CF-locked, zero build deps, cached after first hit, dynamic per-request format selection). Pick the one that fits your hosting story.

Requirements: Cloudflare Image Transformations on the zone (Pro+). Without widths, <Image> renders a plain <img> with no CDN prefix — works on any plan.

Runtime cost: ~500 B brotli in the consuming chunk. phaze unchanged.

src/content.config.ts
import { newCollection } from '@madenowhere/phaze-cloudflare/content'
import { z } from 'zod'
export const collections = {
posts: newCollection({
pattern: 'src/content/posts/**/*.md',
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
draft: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
}),
}
src/pages/blog.tsx
import { getCollection } from 'phaze:content'
export const loader = async () => {
const posts = await getCollection('posts')
return posts.filter((p) => !p.data.draft).sort((a, b) => +b.data.pubDate - +a.data.pubDate)
}

Each entry carries { id, slug, collection, data, body }. The plugin globs the filesystem at build time and emits one import … from '…?raw' per matched file. A tiny YAML subset parser handles the common-case frontmatter (strings, numbers, booleans, dates as strings, arrays of scalars). No markdown renderer in the box — pipe body through marked / markdown-it / unified for HTML, or use MDX directly.

Server-side only. getCollection / getEntry throw on the client; markdown content never reaches the browser. Pair with prerender: for zero-runtime-cost blog pages.

Worker cost: ~2.3 KB brotli + the content itself. Client cost: ~80 B brotli for the SSR-only stub. phaze unchanged.

A page is prerendered to static HTML when it’s marked for it — two ways, unioned into one set:

Per-page opt-inprerender: true in a .phaze page’s ---page fence, or export const prerender = true in a .tsx/.jsx page. The page opts itself in; no central config. Dynamic routes ([id] / [...rest]) are skipped — there’s no single concrete path to bake.

---page
head: { title: 'About' }
prerender: true
---

Plugin-level list — concrete paths in the adapter options, to centralize or to prerender pages whose source you’d rather not annotate:

cloudflare({ prerender: ['/', '/about', '/pricing'] })

Both feed one build-time pass (in closeBundle, production only — watch mode skips it; the worker SSRs live). Each path renders to dist/client/<path>/index.html and is added to _routes.json’s exclude list, so Cloudflare’s static-assets handler serves it directly — the Worker never runs for that path.

How it renders — “Method C”. The pass boots the freshly-built Worker inside Miniflare with your real wrangler.jsonc bindings and dispatches an in-process request per path. It isn’t a parallel render path — it’s the same worker bundle and the same handleRequest a live request hits, so the output is identical to production. Because it’s the real worker, bindings resolve: env.DB / KV / R2 read from local .wrangler/state (the same state wrangler dev sees — applied D1 migrations, wrangler kv writes), so loader data bakes straight into the static HTML. To read the real edge resource at build instead of local state, mark the binding remote: true in wrangler.jsonc. (This replaced an earlier transient-Vite-SSR pass that ran with env: {} — no bindings — where DB/KV reads were impossible at build.)

Cost: 0 bytes added to client or worker bundles. Build-time only.

Works in a prerendered page: head metadata; loader() reading bindings (D1 / KV / R2 — baked from build-time local state); edge signals (initial value captured at build); named slots; phaze:env/client (PUBLIC_*). about/page.phaze is the live example — its ---data runs env.DB.prepare('SELECT … FROM early_access') and the rows ship inside the static HTML.

Rust-backed fences bake too — a rust: action reaches its Rust worker only if that worker is running during the pass. cloudflare({ rustApi }) spawns it (the build-time twin of the dev:hmr orchestration: same spawn / /api/health gate / teardown), so a signed manifest’s real signed bytes bake instead of an error state. Only spawned when the transport file declares rust: fences.

Isomorphic values follow the seed/render split (Flat Buffers & SSR). Everything renders — its text bakes into the HTML. What also seeds (rides __PHAZE_CF__ so the hydrating client adopts it with no fetch) is JSON: loader data, and a JSON direct-fence value the render fetched — so a prerendered direct-fence page paints its content AND the client refetches nothing until the fence’s next revalidate tick. A FlatBuffer bakes its text but not its value (aligned binary can’t sit in a <script>), so its page is content-complete at first paint and the client does one post-hydration fetch for the buffer.

Doesn’t work: anything tied to a live request — cookies, request.cf (device / country / colo), per-request headers. The bake is one fixed snapshot; there’s no request at build. A page that needs per-request divergence stays SSR, or composes client-side ISR (withRevalidate) onto the baked shell to refresh its data on an interval.

Can’t also be edge-cached. A prerendered path is served as a static file — the Worker never runs — so pairing it with export const revalidate (or a literal-path cache.rules entry) is a contradiction: the cache directive lands on a response that’s never served. The build throws a collision error naming the path and which side declared caching. A wildcard cache.rules baseline (e.g. '/**') is exempt — prerender simply opts that path out of the SSR default. (This is the edge-cache revalidate; the client-side withRevalidate above is unrelated and composes fine.)

The framework is in a strong position. Every feature that exists is leaner than the Astro equivalent; the remaining gaps are enumerated and most are small in scope.