zfb

Type to search...

to open search from anywhere

MDX Components

CreatedJun 1, 2026Takeshi Takatsudo

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/note";

<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:

KeyComponentWraps
h2ContentH2<h2>
h3ContentH3<h3>
h4ContentH4<h4>
pContentParagraph<p>
aContentLink<a>
strongContentStrong<strong>
blockquoteContentBlockquote<blockquote>
ulContentUl<ul>
olContentOl<ol>
tableContentTable<table>
codeContentCode<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.Content /> 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.Content /> 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:

  1. defaultComponents (lowest priority — the 11 built-in passthroughs)
  2. mdx-components.tsx default export (project-wide overrides)
  3. components prop on <entry.Content /> (highest priority — per-call overrides)

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
ahref, title (optional), children
imgsrc, alt, title (optional) — void, no children
h2h6id (present when the heading-links plugin is active and has slugged the heading), children
h1children only — h1 is not slugged
prechildren (wraps a <code> child)
codeclassName="language-*" (fenced blocks only; inline code receives children only)
olstart (only when non-1), children
all otherschildren 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 _components map. If you omit a lowercase key, the raw element renders — no error.

  • PascalCase names (Note, Callout, …) are looked up from the caller’s components prop 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 .mdx file that is not present in the components map 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 (full source at the zfb-example-blog standalone repo). The MDX post uses a custom <Note> admonition:

---
title: Hello, zfb
date: 2026-04-20
description: A short introduction to the basic-blog dogfood example.
---

Welcome to the **basic-blog** example.

<Note title="MDX in basic-blog">

  This post is `.mdx`, not plain markdown. The `<Note>` admonition you are
  reading right now is a custom JSX component passed in via the `components`
  prop on `<post.Content />` — exactly the same delivery contract Astro's
  `@astrojs/mdx` exposes.

</Note>

The per-post route resolves <Note> against the components map by passing it in alongside defaultComponents:

// pages/blog/[slug].tsx
import { defaultComponents } from "zfb";
import Note from "../../components/note";

export default function BlogPostPage({ post }: Props) {
  return (
    <article>
      <h1>{post.data.title}</h1>
      <post.Content components={{ ...defaultComponents, Note }} />
    </article>
  );
}

The Note component itself is an ordinary Preact (or React) component that receives title and children:

// components/note.tsx
import type { ComponentChildren } from "preact";

type Props = {
  title?: string;
  children: ComponentChildren;
};

export default function Note({ title, children }: Props) {
  return (
    <aside class="admonition admonition--note" role="note">
      {title ? <strong>{title}</strong> : null}
      <div>{children}</div>
    </aside>
  );
}

The full source lives in the zfb-example-blog standalone repo — see content/blog/hello-zfb.mdx, pages/blog/[slug].tsx, and components/note.tsx. A live build is available at zfb-example-blog.pages.dev.

Reference

  • The defaultComponents set 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 components prop.

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.

Revision History