Example: Blog
A pure-SSG Markdown/MDX blog with three dynamic-route shapes, a zudo-react theme-toggle island, and a fully authored CSS theme, deployed as an assets-only Cloudflare Worker
What this page covers
A complete, deployed zfb blog: a blog content collection of Markdown and MDX posts, three different dynamic-route shapes, one zudo-react "use client" island, and a theme written in authored CSS. It is a pure static build — no SSR, no Cloudflare bindings, no adapter — which makes it the smallest realistic shape of a content-driven zfb site.
Live demo: zfb-example-blog.takazudomodular.com
Repository: Takazudo/zfb-example-blog
What it demonstrates
A content collection named
blog, mixing.mdand.mdxposts in one directory.Three dynamic-route shapes from three route files — per-post (
pages/blog/[slug].tsx), paginated index (pages/), and per-tag (blog/ page/ [page]. tsx pages/tags/[tag].tsx) — each built by its ownpaths()export.paginate()turning a sorted array into thepaths()return value directly.A custom MDX component (
<Note>) reaching the page through thecomponentsprop onentry.Content— see MDX components.One zudo-react island (
ThemeToggle) whose first render is SSR-safe, paired with an inline pre-hydration script so the page never paints in the wrong theme.A theme authored entirely in
styles/, with light and dark CSS custom properties selected byglobal. css data-themerather thanprefers-color-scheme.
Tech used
| Aspect | zfb-example-blog |
|---|---|
| Framework | zfb 3.1.0 with zudo-react; the theme toggle uses the zudo-react island runtime |
| Runtime | @takazudo/zfb-runtime 3.1.0 |
| Styling | Authored CSS in styles/; wind.reset: "owned-v1" plus an authored preflight-parity @layer base block |
| Rendering | Pure SSG. No route sets export const prerender = false |
| Content | one collection — blog → content/blog (4 .md + 1 .mdx) |
| Interactivity | one "use client" island, mounted with <Island when="idle"> |
| Cloudflare surface | Workers Static Assets, assets-only — wrangler.toml has no main key |
| Bindings | none |
| Adapter | @takazudo/zfb-adapter-cloudflare is not a dependency |
| Other deps | wrangler 4.85.0 as a devDependency; no other runtime dependencies |
zfb build emits 14 HTML pages: 1 homepage, 5 posts, 2 paginated index pages, and 6 tag pages. "How it works" below traces where each of those numbers comes from.
Requirements and configuration
Works locally with no Cloudflare account. pnpm install && pnpm build produces the entire site; pnpm preview serves it. There is nothing to provision — no bindings, no environment variables, no Worker secrets, no migrations or seed data. The posts in content/blog/ are the whole database.
Needs Cloudflare only to deploy. The repo's GitHub Actions workflow runs wrangler deploy, which uploads dist/ as the Worker's static assets and attaches the custom domain declared in wrangler.toml. That requires two repo secrets — CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID — and the credentialed jobs self-skip when the token is absent, so forks stay green.
The custom domain needs a zone-scoped permission
wrangler.toml declares custom_domain = true, so the API token must carryZone · Workers Routes · Edit on the target zone in addition to the account-scopedWorkers Scripts · Edit. Without it, wrangler deploy uploads the Worker successfully and thenfails at the route step — the deploy looks half-done and the domain never resolves. The repo'sdocs/ documents the full token recipe.
The live demo is a public, read-only static site: no authentication, nothing writable, no state to corrupt. One rough edge is documented in wrangler.toml: not_found_handling = "404-page" is set, but this project does not emit a dist/, so unmatched paths currently fall back to a bare Cloudflare 404. The setting is correct in advance of a 404 page being added.
How it works
Fourteen pages from four route files
The page count is not a build setting — it falls out of what each paths() export returns, so it is worth tracing.
pages/ is a static route: 1 page. pages/blog/[slug].tsx returns one entry per post, so 5 posts give 5 pages. pages/tags/[tag].tsx walks every post's tags frontmatter into a Map<string, BlogEntry[]> and emits one page per distinct key — the five posts mention deploy, framework, intro, perf, ssr, and tooling, so that is 6 pages.
The remaining 2 come from the paginated index, where paginate() does the arithmetic for you. Its return value is the paths() return value — no adapter step in between:
import type { BlogEntry, BlogFrontmatter } from "../../../lib/types";
export async function paths() {
const { getCollection } = await import("@takazudo/zfb/content");
const { paginate } = await import("@takazudo/zfb/paginate");
const posts = await getCollection<BlogFrontmatter>("blog");
const sorted = [...posts].sort((a, b) => b.data.date.localeCompare(a.data.date));
return paginate(sorted, { pageSize: 3, param: "page" });
}pageSize: 3 over 5 posts yields / and /. Total: 1 + 5 + 6 + 2 = 14.
Note the [...posts] copy before .sort(). The example does this in every route that sorts, on purpose: getCollection() may hand back an array shared between routes, and an in-place sort would silently re-order it for every other consumer.
Avoiding the flash of the wrong theme
The theme toggle is the one interactive thing on the site, and it is the part of the example most worth reading, because a naive implementation breaks in two different ways at once.
An island's first render must produce identical HTML on the server and during client hydration. ThemeToggle starts a signal<Theme> at the deterministic value "light" and derives its button text and ARIA labels with computed, so it does not read browser state during setup. After the island adopts its server HTML, scope.onActivate reads the saved or system preference; scope.effect then mirrors changes to the document and storage. The button changes the signal through on:click.
The inline script solves the separate first-paint problem. It sets the theme attribute synchronously in <head>, before the stylesheet is parsed, so the first painted frame already uses the chosen palette regardless of when the island activates.
const THEME_BOOTSTRAP_SCRIPT = `(() => {
try {
var saved = localStorage.getItem("basic-blog:theme");
var hasMM = typeof window !== "undefined" && typeof window.matchMedia === "function";
var theme = saved === "light" || saved === "dark"
? saved
: (hasMM && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.dataset.theme = theme;
} catch (e) {
document.documentElement.dataset.theme = "light";
}
})();`;The stylesheet reads that same attribute. styles/ declares the light --blog-* custom properties on :root and overrides them under [data-theme="dark"]; all layout, component, and prose styling is authored CSS, with no utility classes. zfb.config.ts sets zudo-wind's reset: "owned-v1". An additional authored @layer base block restores selected preflight-parity rules that the reset does not include. See Coming from Tailwind for the measured preflight comparison.
The layout itself stays a plain server component; only the toggle ships JavaScript, and <Island when="idle"> keeps even that until the browser has spare cycles.
A custom component inside MDX
content/ writes <Note title="MDX in basic-blog">. That resolves because the per-post route passes Note in alongside defaultComponents when it renders the entry body:
<post.Content components={{ ...defaultComponents, Note }} />defaultComponents supplies the HTML-tag overrides (<p>, <a>, headings, lists); spreading it first means individual entries on the right win on key collisions. The <Note> component itself is deliberately plain — its job in this example is to prove the delivery contract, not to be a good-looking admonition.
Run it locally
Requires Node.js >= 22.12.0 and pnpm 10.x. A plain pnpm install brings the zfb CLI with it — no Rust toolchain and no upstream checkout.
pnpm install
pnpm dev # zfb dev server with live reload
pnpm build # static build into dist/ (14 pages)
pnpm preview # serve the built dist/ locally
pnpm typecheck # zfb check — collection validation + tsc --noEmitBecause the site is pure SSG, there is no Worker-scope caveat here: everything zfb dev serves is what production serves. Two small things to know — predev runs rm -rf dist .zfb .zfb-build, so every pnpm dev starts from a cold build, and pnpm typecheck is zfb check, which validates the collection in addition to running tsc.
See also
Content collections — the
blogcollection this site is built on.Dynamic routes and Routing — the
paths()contract behind all three route shapes.paginate()— the pagination helper used verbatim above.Islands and
<Island>— the hydration boundary and its scheduling strategies.Coming from Tailwind — the measured preflight comparison and authored CSS guidance.
Static assets — what ends up in
dist/next to the HTML.