zfb
GitHub repository

Type to search...

to open search from anywhere

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 .md and .mdx posts in one directory.

  • Three dynamic-route shapes from three route files — per-post (pages/blog/[slug].tsx), paginated index (pages/blog/page/[page].tsx), and per-tag (pages/tags/[tag].tsx) — each built by its own paths() export.

  • paginate() turning a sorted array into the paths() return value directly.

  • A custom MDX component (<Note>) reaching the page through the components prop on entry.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/global.css, with light and dark CSS custom properties selected by data-theme rather than prefers-color-scheme.

Tech used

Aspectzfb-example-blog
Frameworkzfb 3.1.0 with zudo-react; the theme toggle uses the zudo-react island runtime
Runtime@takazudo/zfb-runtime 3.1.0
StylingAuthored CSS in styles/global.css; wind.reset: "owned-v1" plus an authored preflight-parity @layer base block
RenderingPure SSG. No route sets export const prerender = false
Contentone collection — blog → content/blog (4 .md + 1 .mdx)
Interactivityone "use client" island, mounted with <Island when="idle">
Cloudflare surfaceWorkers Static Assets, assets-only — wrangler.toml has no main key
Bindingsnone
Adapter@takazudo/zfb-adapter-cloudflare is not a dependency
Other depswrangler 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/cloudflare-setup.md 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/404.html, 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/index.tsx 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:

pages/blog/page/[page].tsx
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 /blog/page/1 and /blog/page/2. 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.

layouts/default.tsx
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/global.css 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/blog/hello-zfb.mdx 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:

pages/blog/[slug].tsx
<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 --noEmit

Because 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

Revision History

CreatedUpdated