zfb

Type to search...

to open search from anywhere

Migrating from Astro

CreatedJun 1, 2026Takeshi Takatsudo

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.

Astrozfb
src/pages/pages/
src/layouts/layouts/
src/components/components/
src/content/content/
astro.config.mjszfb.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.

Revision History