MDX Components
How MDX entries reach your pages as renderable React/Preact components, and how to override individual elements via the components prop.
What this page covers
How .mdx content collection entries become renderable JSX components, what arrives by default through getCollection(), how to override individual HTML elements with your own components, how the global mdx-components.tsx convention works, and the complete props contract each override receives.
The mental model
Every .mdx file in a content collection is compiled to a JSX module at build time. The compiled module exports an MDXContent({ components }) function — a regular component whose only job is to render the post body, with optional per-element overrides supplied through the components prop.
This is the same contract Astro's @astrojs/mdx exposes via <Content components={...} />. zfb adopts it directly so the mental model transfers across the Astro ecosystem.
The shape is:
// Conceptually, your `hello-zfb.mdx` becomes:
export default function MDXContent({ components }: { components?: Record<string, unknown> }) {
// ...renders the post body, looking up <p>, <a>, <Note>, etc. in `components`
}When MDX encounters <p>some text</p> (which is what a markdown paragraph compiles to), it looks up p in the components map. If you supply one, your component renders. If you don't, the raw HTML element renders. The same lookup applies to your custom JSX — <Note>...</Note> in the post body is resolved against components.Note at evaluation time.
What getCollection() already gives you
You don't construct MDXContent yourself. The collection layer wraps every entry's compiled module in a Content component and attaches it to the entry. That means:
import { getCollection } from "zfb/content";
const posts = getCollection("blog");
const post = posts[0];
// `post.Content` is a renderable component:
<post.Content />;You get post.data (the parsed frontmatter), post.slug, and post.Content (the renderable body). Same shape for .md and .mdx — CommonMark is a strict MDX subset, so the same pipeline handles both. Plain markdown entries simply have no JSX nodes to override, but the components prop still applies to the HTML elements they emit.
The bridge that connects post.Content to the actual compiled JSX module lives at globalThis.__zfb.content.get(specifier), installed by the Rust-side renderer before the page module evaluates. When the bridge isn't present (unit tests, dev sandboxes), Content falls back to a clearly marked <pre data-zfb-content-fallback> block so the absence is obvious.
Spreading defaultComponents
zfb ships a defaultComponents map from its root export — a set of element-level overrides (the htmlOverrides convention) that wrap the bare HTML tags MDX emits with sensible component implementations. The recipe is to spread it first, then layer your own overrides on top:
import { defaultComponents } from "zfb";
import { Note } from "../../components/callout";
<post.Content components={{ ...defaultComponents, ...mine, Note }} />;Spread order matters: keys to the right win. Putting defaultComponents first means your individual overrides take precedence on collisions, while everything you don't override still picks up the default behavior.
The current defaultComponents set covers:
| Key | Component | Wraps |
|---|---|---|
h2 | ContentH2 | <h2> |
h3 | ContentH3 | <h3> |
h4 | ContentH4 | <h4> |
p | ContentParagraph | <p> |
a | ContentLink | <a> |
strong | ContentStrong | <strong> |
blockquote | ContentBlockquote | <blockquote> |
ul | ContentUl | <ul> |
ol | ContentOl | <ol> |
table | ContentTable | <table> |
code | ContentCode | <code> (inline; block code is unaffected) |
Each is also exported individually (import { ContentLink } from "zfb") for cases where you want a single override without dragging in the whole map.
h1 is intentionally absent: page titles render <h1> from frontmatter, per the zudo-doc convention. Adding h1 to your overrides would silently double-render the title.
The global mdx-components.tsx convention
For project-wide element overrides you want applied to every <entry. call without per-call spreading, place an mdx-components.tsx file at your project root (next to zfb.config.ts) with a default export that is a flat { tag: Component } map:
// mdx-components.tsx (project root)
import MyH2 from "./components/my-h2";
export default {
h2: MyH2,
};The build pipeline discovers this file, copies it into the shadow bundle, and installs its default export on globalThis.__zfb.mdxComponents before the page router runs. From that point every <entry. call picks it up automatically — no spreading required at the call site.
Precedence order
When components are merged, later entries win. The merge order is:
defaultComponents(lowest priority — the 11 built-in passthroughs)mdx-components.tsxdefault export (project-wide overrides)componentsprop on<entry.(highest priority — per-call overrides)Content / >
So a per-call components={{ h2: MyCallSiteH2 }} beats the project-root file, which beats the built-in defaults. Under the hood, mergeMdxComponents (exported from "zfb") performs this three-way spread:
{ ...defaultComponents, ...globalSlot, ...perCall }Runnable example — wrapping <h2> with a <span>
A common pattern is to wrap headings in a styled container:
// mdx-components.tsx (project root)
type Props = { id?: string; children?: unknown };
function MyH2({ id, children }: Props) {
return (
<h2 id={id}>
<span>{children}</span>
</h2>
);
}
export default { h2: MyH2 };Every ## heading in every content entry now renders <h2 id="…"><span>…</span></h2> project-wide, with no per-page wiring needed.
The canonical mappable element set
The following HTML tags are routed through _components.<tag> by the MDX emitter, making them overridable via the components map. Tags not on this list are either not emitted by markdown at all, or emit as raw HTML literals (not overridable by the components map).
Core CommonMark:
a blockquote br code em h1 h2 h3 h4 h5 h6 hr img li ol p pre strong ul
GFM extensions (available when GFM constructs are enabled in zfb.config.ts):
del input section sup table tbody td th thead tr
The defaultComponents map pre-wires 11 of these (h2–h4, p, a, strong, blockquote, ul, ol, table, code). The rest default to their plain HTML string, but you can override any of them by including the key in your components map.
Props contract
Each override component receives only the attributes that the markdown pipeline actually produces for that element. There is no universal {...props} spread of arbitrary HTML attributes — only the fields listed below are passed:
| Element(s) | Props received |
|---|---|
a | href, title (optional), children |
img | src, alt, title (optional) — void, no children |
h2–h6 | id (present when the heading-links plugin is active and has slugged the heading), children |
h1 | children only — h1 is not slugged |
pre | children (wraps a <code> child) |
code | className="language-*" (fenced blocks only; inline code receives children only) |
ol | start (only when non-1), children |
| all others | children only |
If you add {...rest} to an override and spread it onto the HTML element, no unknown attributes will arrive — the emitter does not forward arbitrary attributes from the markdown source.
Lowercase vs. PascalCase asymmetry
There is a deliberate behavioural difference between lowercase HTML tags and PascalCase component names in the components map:
Lowercase tags (
h2,p,a, …) default to their plain HTML string in the emitted_componentsmap. If you omit a lowercase key, the raw element renders — no error.PascalCase names (
Note,Callout, …) are looked up from the caller'scomponentsprop with a hard throw:// Inside compiled MDX output: const Note = _components.Note ?? components.Note; if (!Note) throw new Error("MDX requires `Note` to be passed via the `components` prop");A PascalCase component referenced in an
.mdxfile that is not present in thecomponentsmap at render time throws at runtime — not at build time. This is intentional: the compiled module stays portable; the caller is responsible for passing all required components.
Islands constraint
A components map is a plain JavaScript object resolved at SSR time. It cannot cross the hydration boundary as JSON, because the values are function references. This means:
Components in the map are server-side only. They render on the server and their HTML is delivered to the browser as static markup.
If a component in your map needs client-side interactivity (state, event handlers, browser APIs), wrap the interactive part in an
<Island>component instead of putting the interactive component directly in the map.
// Correct: interactive content goes through Island
import { Island } from "zfb";
import MyCounter from "./my-counter";
function WrappedCounter() {
return (
<Island>
<MyCounter />
</Island>
);
}
export default { div: WrappedCounter };The components map runs through SSR; <Island> is the escape hatch for the browser.
Deferred open item — wrapper
MDX supports a special wrapper key in the components map: a component that wraps the entire rendered output. zfb's emitter does not recognise wrapper — the body is wrapped in <_Fragment> directly, and no lookup against a wrapper key is performed. Support for wrapper is a tracked open item and is not implemented in the current release.
Authoring .mdx with custom components
The bundled basic-blog template demonstrates the end-to-end shape. Its hello-zfb.mdx post writes a <Tip> admonition as JSX directly in the body:
---
title: Hello, zfb
date: 2026-04-20
description: What this starter contains and how the pieces fit together.
---
Welcome to **basic-blog**. This is a working zfb site: a content collection,
a handful of routes, one interactive island, and Tailwind for styling.
<Tip>
Run `zfb check` (or `pnpm typecheck`) to typecheck the project **and**
validate every post's frontmatter against the collection schema.
</Tip>Nothing in the per-post route wires <Tip> up. The route passes defaultComponents and nothing else:
// pages/blog/[slug].tsx
import { defaultComponents } from "@takazudo/zfb";
export default function BlogPostPage({ post }: Props) {
return (
<article>
<h1>{post.data.title}</h1>
<post.Content components={{ ...defaultComponents }} />
</article>
);
}The admonitions arrive through the project-root mdx-components.tsx, which zfb merges into every entry:
// mdx-components.tsx (project root)
import { Note, Tip, Important, Warning, Caution } from "~/components/callout";
export default { Note, Tip, Important, Warning, Caution };Putting the five names there rather than in each route is what makes them work in plain .md posts too. The template enables markdown.features.githubAlerts in its zfb.config.ts, so a > [!NOTE] blockquote is rewritten into a <Note> element before rendering — and a PascalCase name missing from the map throws at render time. Wiring them per route would mean every route that renders a post has to remember all five.
The components themselves are ordinary Preact (or React) components. components/ exports one base component plus five named wrappers, each taking only children:
// components/callout.tsx
import type { ComponentChildren } from "preact";
type Variant = "note" | "tip" | "important" | "warning" | "caution";
const LABELS: Record<Variant, string> = {
note: "Note",
tip: "Tip",
important: "Important",
warning: "Warning",
caution: "Caution",
};
export function Callout({ variant, children }: { variant: Variant; children: ComponentChildren }) {
return (
<aside data-component={variant} role="note">
<p>{LABELS[variant]}</p>
<div>{children}</div>
</aside>
);
}
export function Note({ children }: { children: ComponentChildren }) {
return <Callout variant="note">{children}</Callout>;
}
// …Tip, Important, Warning, and Caution are one line each, like Note.The five component names are fixed by the feature, not chosen by you — see GitHub alerts for the alert syntax, and Project structure for where these files sit in the scaffold.
Reference
The
defaultComponentsset is zfb's port of the htmlOverrides convention pioneered by zudo-doc, where the same map shape solves the same problem for that documentation framework.The upstream pattern from Astro is documented at Astro: Assigning custom components to HTML elements. The contract is intentionally identical: a flat record of element-name → component, passed through the
componentsprop.
See also
Content Collections — how entries are discovered and exposed via
getCollection().Build Pipeline — where MDX-to-JSX compilation sits in the end-to-end flow.
Custom Directives — adding new MDX directive syntax (e.g.
:::callout) that maps to your JSX components.Islands — how to make interactive components work inside a content entry.