zfb
GitHub repository

Type to search...

to open search from anywhere

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.

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 using the owned zudo-react runtime. The same JSX dialect is used for layouts, pages, and components; interactive state lives in signals rather than render-time hooks. See the zudo-react overview for its component model and limits.

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 = getCollection("blog");  // synchronous — no await

The return shape is similar — each entry exposes slug, data (frontmatter), and body. See getCollection 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 — schema validation uses a minimal JSON-Schema dialect. The schema field accepts a JSON object with type and/or properties keys. Valid type values are "string", "number", "integer", "boolean", "array", "object", and "null" (no "date", no trailing ? for optional). The schema is validated for structure at config load time but frontmatter fields are not currently enforced against it at build time. Run zfb check to validate collection frontmatter against the declared schemas; that is the enforcement gate for CI. See defineConfig 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 and an explicit <Island> boundary:

"use client";

import { signal } from "@takazudo/zfb/zudo-react";

export default function Counter() {
  const n = signal(0);
  return <button on:click={() => { n.value += 1; }}>{n}</button>;
}

"use client" marks a client boundary, but does not activate an island by itself. Wrap the target component in an explicit <Island>. The setup runs once; signal bindings update the island DOM. See Island.

MDX component attributes client:load, client:idle, client:visible, client:media, and client:only produce source-located ZB014 warnings. When the component ignores these props they have no effect on activation; forwarding one to a DOM element can fail rendering with ZR_ATTRIBUTE. Use an explicit <Island> boundary.

Slots and layouts

Astro's <slot /> becomes plain JSX children:

export default function DocsLayout({ children }) {
  return <div class="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 and an explicit <Island> boundary; 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 defineConfig.

Plugin lifecycle: setup, addAlias, addVirtualModule, injectRoute, addClientEntry

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, addClientEntry }) {
    addAlias("@/components/foo", "./src/components/foo.tsx");
    addVirtualModule("virtual:my-data", () =>
      `export default ${JSON.stringify({ key: "value" })}`,
    );
    injectRoute("/preset-page", "./pages/preset-page.tsx");
    addClientEntry("./client/analytics.client.ts");
    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 not dev-only: during zfb build, package-owned routes are materialised into the build overlay and prerendered through the normal route pipeline; during zfb dev, they are synthetic routes served by the dev renderer. Gate a route on command === "dev" only when you intentionally want that route to exist in dev but not in production. addClientEntry registers a package-owned *.client.{ts,tsx,js,jsx} side-effect entry for the client bundle. 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

Some Astro features have no one-to-one engine API in zfb. The examples below show how to express the closest user-owned pattern with zudo-react or with ordinary route and layout composition.

client:media — conditional hydration by media query

Astro's client:media="(max-width: 768px)" defers island activation until a media query matches. zfb expresses the same scheduling at the island boundary with when="media" and a required query:

import { Island } from "@takazudo/zfb";
import MobileMenu from "./MobileMenu";

export default function Header() {
  return <Island when="media" media="(max-width: 768px)"><MobileMenu /></Island>;
}

The island is activated when the query first matches; the media query schedules activation and does not repeatedly mount and unmount the component on later changes.

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.

The runtime has no context or child-to-layout registry. Pass head descriptions to the layout explicitly so the data flow does not depend on render order:

// layouts/base.tsx
import type { Child } from "@takazudo/zfb/zudo-react";

export default function BaseLayout({ children, head }: { children: Child; head?: Child }) {
  return (
    <html>
      <head><meta charset="utf-8" />{head}</head>
      <body><main>{children}</main></body>
    </html>
  );
}
// pages/blog/[slug].tsx
import BaseLayout from "../../layouts/base";

export default function BlogPost({ post }) {
  return (
    <BaseLayout head={<><title>{post.data.title}</title><meta name="description" content={post.data.description} /></>}>
      <article>{/* ... */}</article>
    </BaseLayout>
  );
}

For routes that get their metadata from collection data, compute those values in the route and pass the resulting descriptions as head content.

Removing Astro component-bridge aliases

Some Astro projects register aliases like @/components/svg and @/components/responsive-image to bridge .astro components into JSX islands.

In zfb every component is .tsx; rewrite each bridged component and point imports at its .tsx implementation. The intermediate alias can usually be removed or simplified:

// 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.

Custom markers can belong to a separate widget system, but they are not zfb island boundaries. Use zfb's <Island> helper for components owned by its client runtime; it supplies the wrapper identity, strict JSON props transport, scheduling, and client entry.

// pages/blog/[slug].tsx
import { Island } from "@takazudo/zfb";
import MyWidget from "./my-widget";

export default function BlogPost() {
  return (
    <article>
      <Island when="visible"><MyWidget label="Click me" /></Island>
    </article>
  );
}

The island child must be one registered synchronous component, and its props must be strict JSON data. Do not hand-author a partial data-zfb-island wrapper; persisted wrappers require the complete transport metadata documented in the Island API.

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

CreatedUpdated