Migrating from Astro
A concept-by-concept map for moving an existing Astro static site to zfb.
If you already maintain an Astro site, most of zfb will feel familiar. Both projects ship file-based routing, content collections, and partial hydration. The differences are mostly about scope: zfb is smaller, more opinionated, and Rust-backed end to end.
Directory layout
Astro puts everything under src/. zfb does not.
| Astro | zfb |
|---|---|
src/pages/ | pages/ |
src/layouts/ | layouts/ |
src/components/ | components/ |
src/content/ | content/ |
astro.config.mjs | zfb.config.ts |
The flatter layout matches the smaller surface area. There is no src/
namespace to remember.
Components: one model, not two
Astro’s headline feature is the .astro file — a server-only template
language with optional islands of framework code. zfb collapses that into
a single component model: every component is a .tsx file. The framework
field in zfb.config selects either Preact or React, and the same JSX
flavor is used for layouts, pages, and components.
export default function MarketingPage() {
return <main><h1>Hello</h1></main>;
}
There is no separate template syntax to learn, but if you relied on
.astro for things like top-level await outside components, you will
need to move that work into a paths() export or a data collection.
Content collections
Astro’s getCollection("blog") from astro:content has a near-direct
equivalent in zfb:
import { getCollection } from "zfb/content";
const posts = await getCollection("blog");
The return shape is similar — each entry exposes slug, data
(frontmatter), and body. See /api/get-collection for the full surface.
Schemas move from inline Zod to declarative config. Declare collections in
zfb.config.ts using defineConfig:
// zfb.config.ts
import { defineConfig } from "zfb/config";
export default defineConfig({
collections: [
{ name: "blog", path: "content/blog" },
],
});
There is no Zod — front-matter field validation is expressed as string tags
("string", "number", "date", with a trailing ? for optional) inside
the optional schema field. See <code>defineConfig</code> for
the full shape.
Islands
Astro uses client:load, client:visible, and friends as JSX attributes
on imported framework components. zfb uses a file-level "use client"
directive instead — the same pattern Next.js App Router popularized:
"use client";
export default function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
Anything imported from a "use client" file becomes an island
automatically. See /api/island.
Slots and layouts
Astro’s <slot /> becomes plain JSX children:
export default function DocsLayout({ children }) {
return <div className="prose">{children}</div>;
}
What zfb ships differently
A few Astro features have direct zfb equivalents under a different name:
- View transitions — use
<ClientRouter />from@takazudo/zfb-runtime(the same SPA router with view-transition animations that Astro’s<ClientRouter />provides). - Islands / client directives — replaced by the
"use client"file directive (the Next.js App Router pattern); see Islands.
Some features have no current equivalent: an integrations marketplace, Astro DB, and server endpoints. If your site relies on these, evaluate whether the zfb plugin system or an adapter covers your use case before committing to a port.
Engine additions since v0
The following capabilities were not part of the initial zfb release but are available today. If your Astro site used the equivalent patterns, here is where to reach in zfb:
Canonical site URL (site)
Astro’s site config key maps directly to zfb’s top-level site option:
// zfb.config.ts
export default defineConfig({
site: "https://example.com",
});
When set, globalThis.__zfb.site is available at render time so layouts can
build canonical <link> tags and OpenGraph meta. See
<code>defineConfig</code>.
Plugin lifecycle: setup, addAlias, addVirtualModule, injectRoute
Astro integrations expose updateConfig, addWatchFile, and injectRoute
during the astro:config:setup hook. zfb’s equivalent is the setup hook in
the plugin lifecycle:
import { definePlugin } from "@takazudo/zfb/plugins";
export default definePlugin({
name: "my-plugin",
setup({ command, addAlias, addVirtualModule, injectRoute }) {
addAlias("@/components/foo", "./src/components/foo.tsx");
addVirtualModule("virtual:my-data", () =>
`export default ${JSON.stringify({ key: "value" })}`,
);
if (command === "dev") {
injectRoute("/api/dev/x", "./scripts/dev-x.ts");
}
},
});
addAlias performs exact-match import rewrites (prefix matching is a future
revision). addVirtualModule exposes a synthetic ESM module by specifier.
injectRoute is dev-only; calling it during zfb build is an error.
See Plugins for the full contract.
Route manifest in postBuild (ctx.routes)
Astro exposes a injectRoute + astro:build:done pattern for post-build
work. In zfb, postBuild plugins receive ctx.routes — the complete manifest
of every URL the build emitted:
postBuild({ outDir, routes }) {
const htmlRoutes = routes.routes.filter((r) => r.extension === "html");
// write sitemap, feed, etc.
},
See the Plugins page for the full ZfbRouteManifest
shape and a worked sitemap example.
Markdown: Table of Contents, external links, CJK-friendly emphasis
Astro users often reach for remark-toc, rehype-external-links, or CJK
patches as integrations. In zfb, these are built-in opt-ins inside the
markdown config block:
// zfb.config.ts
export default defineConfig({
markdown: {
toc: { heading: "TOC", maxDepth: 2 },
externalLinks: { target: "_blank", rel: ["noopener", "noreferrer"] },
cjkFriendly: true, // on by default
},
});
See Customizing Markdown for full details.
Custom theme files for syntax highlighting (codeHighlight.themesDir)
Astro’s shiki integration accepts any Shiki theme name. zfb uses syntect
(Sublime Text–compatible .tmTheme files). Point codeHighlight.themesDir
at a directory containing your .tmTheme files:
export default defineConfig({
codeHighlight: {
themesDir: "./themes",
theme: "Dracula",
},
});
See Syntax Highlighting.
Patterns that move to user space
zfb deliberately omits some Astro features. This is not oversight — these patterns have clean userland solutions in a pure-JSX/Preact model that do not need engine support. The sections below show the recommended recipe for each.
client:media — conditional hydration by media query
Astro’s client:media="(max-width: 768px)" defers hydration until a media
query matches. zfb’s "use client" directive always hydrates on load.
The Preact equivalent is an early return inside the component itself:
"use client";
import { useEffect, useState } from "preact/hooks";
interface Props {
children: preact.ComponentChildren;
}
/** Renders children only on viewports that match the query. */
export default function MediaMount({ children }: Props) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 768px)");
setMatches(mq.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
if (!matches) return null;
return <>{children}</>;
}
Use this as a wrapper: <MediaMount><MobileMenu /></MediaMount>. The wrapped
component only mounts when the query matches; it unmounts when the query stops
matching.
Per-page <head> additions (Astro’s <Fragment slot="head">)
Astro lets pages inject arbitrary <head> elements via <Fragment slot="head">.
zfb does not have a slot mechanism at the engine level.
📝 Note
PageMeta currently rejects unknown fields; if that constraint relaxes
later, this recipe will get simpler.
The userland pattern is a Preact context-based helmet. The layout renders the page body first (populating the collector), then emits the collected head nodes. This avoids a second render pass:
// components/head-context.tsx
import { createContext } from "preact";
import { useContext, useRef } from "preact/hooks";
interface HeadContextValue {
nodes: preact.VNode[];
add(node: preact.VNode): void;
}
export const HeadContext = createContext<HeadContextValue>({
nodes: [],
add() {},
});
export function useHead() {
return useContext(HeadContext);
}
// components/head.tsx — drop inside a page to register head nodes
import { useHead } from "./head-context";
interface Props {
children: preact.VNode | preact.VNode[];
}
export function Head({ children }: Props) {
const { add } = useHead();
const registered = useRef(false);
if (!registered.current) {
registered.current = true;
const nodes = Array.isArray(children) ? children : [children];
nodes.forEach(add);
}
return null; // renders nothing inline
}
// layouts/base.tsx
import { useState } from "preact/hooks";
import { HeadContext } from "../components/head-context";
export default function BaseLayout({ children }: { children: preact.ComponentChildren }) {
const [nodes] = useState<preact.VNode[]>([]);
const ctx = { nodes, add: (n: preact.VNode) => nodes.push(n) };
// Render children first so Head calls populate `nodes` before we emit <head>.
const body = <main>{children}</main>;
return (
<HeadContext.Provider value={ctx}>
<html>
<head>
<meta charSet="utf-8" />
{nodes}
</head>
{body}
</html>
</HeadContext.Provider>
);
}
// pages/blog/[slug].tsx
import { Head } from "../../components/head";
export default function BlogPost({ post }) {
return (
<>
<Head>
<title>{post.data.title}</title>
<meta name="description" content={post.data.description} />
</Head>
<article>{/* ... */}</article>
</>
);
}
⚠️ SSR render order
This pattern relies on Preact rendering children before siblings in the same
pass. It works for zfb’s static-build SSR (top-down rendering, children
before parent {nodes} emission). If you add a streaming SSR adapter,
verify that the head is flushed after body rendering completes.
Preact-compat aliases (@/components/svg, @/components/responsive-image)
Some Astro projects register aliases like @/components/svg and
@/components/responsive-image to work around Astro’s component model —
for example, bridging .astro SVG components into JSX islands.
In zfb every component is .tsx from the start; the bridging layer those
aliases provided does not exist. During migration, remove these aliases and
import the components directly by their file path (or by a simpler alias
pointing straight at the .tsx file):
// Before (Astro workaround — remove):
// addAlias("@/components/svg", "./src/components/astro-svg-bridge");
// After (zfb — import directly or use a simple alias):
addAlias("@/svg", "./components/svg.tsx");
If the aliased file was an Astro component (.astro) you will need to
rewrite it as a .tsx component first.
Custom hydration with data-island markers
Astro islands serialize component props as JSON in <script type="application/json">
tags next to the rendered HTML. Some projects extend this with custom
data-island attributes for fine-grained control.
This pattern ports byte-for-byte to zfb — you own the full hydration
strategy. Ship your own <script> that queries the markers and calls
Preact’s hydrate():
"use client";
// components/island-hydrator.tsx — add to your root layout as a "use client" island
import { hydrate } from "preact";
import { useEffect } from "preact/hooks";
import MyWidget from "./my-widget";
const REGISTRY: Record<string, preact.ComponentType> = {
"my-widget": MyWidget,
// add more components here
};
export default function IslandHydrator() {
useEffect(() => {
document.querySelectorAll<HTMLElement>("[data-island]").forEach((el) => {
const name = el.dataset.island!;
const Component = REGISTRY[name];
if (!Component) return;
const props = JSON.parse(el.dataset.props ?? "{}");
hydrate(<Component {...props} />, el);
});
}, []);
return null;
}
Then in server-rendered markup, emit <div data-island="my-widget" data-props='{"label":"Click me"}' />.
Custom remark / rehype plugins
Astro accepts arbitrary remark and rehype plugins via
remarkPlugins / rehypePlugins in astro.config.mjs.
zfb’s Markdown pipeline is Rust-backed. There is no npm-level plugin loader for
remark or rehype. Markdown-level customisation ships as an in-tree Rust
visitor — a MdastVisitor or HastVisitor implementation compiled into the
binary.
For most sites this is not a migration blocker: the common built-in remark
plugins (TOC, external links, CJK fixing) are already available as first-class
options in zfb.config.ts. If you relied on a custom plugin for something
more specific, see
Extending the Markdown Pipeline for
the in-tree Rust visitor path.