Skip to content

MDX & Components#

View Markdown

Ox Content lets you embed framework components inside Markdown and .mdx files. It is worth understanding how this works, because it differs from "classic" MDX:

  • JSX elements, module-level import / export, and prose {expression} parse when MDX is enabled. That is the default for .mdx files. With mdx: true / ParserOptions.mdx, you can enable the same path for every configured extension. The Rust parser turns PascalCase and member-name tags into MdxJsxFlowElement / MdxJsxTextElement nodes (self-closing or open/close, with literal, boolean, {expr}, and spread attributes), turns file-level import / export into MdxjsEsm nodes, and turns document-level {foo} / Hello {name} into MdxFlowExpression / MdxTextExpression. Fragments (<>...</>), JSX comments, and {expression} children are stored as AST source. Nothing is evaluated. .md stays CommonMark + GFM unless that option is on.
  • Components are resolved by a framework plugin, not the renderer. The HTML renderer turns named MDX JSX into island placeholders and serializes props (literals as JSON, {expression} / spreads as source). For .mdx files (or when mdx: true), the React/Vue/Svelte/Solid plugins walk the MDX AST — or the rendered data-ox-island names — and import hydration modules for names in the global components map or a relative import resolved from that document. Nested JSX, expression attributes, and fragments are visible to that walk. Unregistered JSX without a matching import stays static HTML. Plain .md still uses a source scan of the global map so existing pages keep working. Expressions are stored and evaluated later.

So you get Markdown's speed for prose plus real interactive components where you need them — without shipping a JavaScript bundle for pages that have none.

Defaults#

When mdx is omitted, Ox Content infers it from the source extension:

Source Default mdx: true mdx: false
.mdx MDX on (JSX, ESM, {expression}) MDX on CommonMark + GFM
.md / .markdown CommonMark + GFM MDX on CommonMark + GFM

You do not need mdx: true for .mdx files. Set mdx: true / ParserOptions.mdx only when you want the same syntax in .md files. Set mdx: false to keep .mdx on the plain Markdown path.

Static HTML vs islands#

Without a framework plugin, the HTML renderer stays on the static path:

  • Lowercase / custom HTML tags (<div>, <note>) stay HTML. They are not islands.
  • PascalCase / member-name tags (<NoteCard />, <Icons.Star />) become data-ox-island placeholders. Props are serialized; nothing is hydrated until a React / Vue / Svelte / Solid plugin is present.
  • Module-level import / export become MdxjsEsm nodes. They do not appear in the HTML and are not executed.
  • Prose {expression} is stored as AST source and is not evaluated. The static HTML renderer currently emits nothing for those nodes — the source is not leaked as text or as JavaScript.

A runnable Vite + @ox-content/vite-plugin site with real .mdx pages is examples/mdx.

Setup#

Add the plugin for your framework alongside its official Vite plugin and point it at your components:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { oxContentReact } from "@ox-content/vite-plugin-react";

export default defineConfig({
  plugins: [
    react(),
    oxContentReact({
      srcDir: "docs",
      // Auto-discover components by glob…
      components: "./src/components/*.tsx",
      // …or map names explicitly:
      // components: { Counter: "./src/components/Counter.tsx" },
    }),
  ],
});

Vue, Svelte, and Solid work the same way via @ox-content/vite-plugin-vue (oxContentVue), @ox-content/vite-plugin-svelte (oxContentSvelte), and @ox-content/vite-plugin-solid (oxContentSolid). When components is a glob, the component name is the PascalCased file name.

The Solid integration additionally has to run before @solidjs/vite-plugin, which must be given the Markdown extensions — see its reference page.

Document-local imports#

On .mdx (or when mdx: true), a document can import a component relative to itself instead of registering it in the site-wide components map:

import GtvChart from './gtv-chart/GtvChart.tsx'

<GtvChart title="ok" />

The specifier is resolved from that file's directory. The binding is local to the document: two pages may both import Chart from different files without sharing one global name. Only the components that page actually uses are imported into the generated module, as static imports, so changing the component file invalidates the Markdown module through Vite HMR.

Form Resolved as an island?
import Name from './file.tsx' Yes, if <Name /> is used
import { Chart as Plot } from './file.tsx' Yes, if <Plot /> is used
Bare / npm / https: specifier No. Reported, not resolved
../ that leaves srcDir No. Diagnostic, no import
Same name in the document import and the global map Document import wins for that file
.md without mdx: true No. ESM is not a document import

The global components map remains the backwards-compatible fallback for pages that do not declare a local import. Framework plugins may also pass an optional renderIsland(name, props, filePath, slotHtml) hook to replace island inner HTML at transform time. Adapter-rendered HTML is marked as server-rendered so the client runtime can hydrate it instead of mounting a duplicate subtree. That hook lives on the adapter; the core renderer does not import react-dom/server, svelte/server, or @solidjs/web.

Authoring components in Markdown#

Write components as PascalCase tags in your Markdown — self-closing or with children:

# My Page

Regular **Markdown** prose. Hello {name}.

{count + 1}

<Counter initial={5} />

<Callout type="tip">

# Title

Hello **world**.

- nested
  - list

<Badge />

</Callout>

<>
<Icons.Star />
{label}
</>

<Card {...cardProps} />

{/_ Hidden from the rendered page _/}

Only tags that start with an uppercase letter are treated as JSX / components, so ordinary HTML (<div>, <span>, …) stays raw HTML. Member names (Foo.Bar), fragments (<>...</>), spreads ({...props}), JSX comments ({/* note */}), {expression} children, and document-level {expression} are parsed when MDX is on; expression source is stored and not run. Tags inside fenced code blocks and inline code are not components or expressions.

Module-level import and export at the start of a file (and after other ESM) become MdxjsEsm nodes. Multi-line statements are collected with a naive brace / paren / string / comment scan — not a JavaScript parser — so regex literals and ${} inside templates may confuse statement boundaries. import / export inside fences or inline code is not ESM. Hostile strings such as import x from "<script>" store source and do not panic. The HTML renderer currently emits nothing for MdxjsEsm or {expression} nodes; framework plugins will resolve imports and evaluate expressions later.

When MDX is active, the generated Vite module also exports structured metadata collected from those AST nodes. User JavaScript is not executed during transform, and import statements are not re-emitted as live ESM — they are JSON data:

import { html, frontmatter, toc, imports, exports, components } from "./guide.mdx";

html;
// string — rendered HTML (islands, no live imports)

frontmatter;
// object — parsed YAML

toc;
// array — heading tree

imports;
// [
//   {
//     source: "./Alert",
//     specifiers: [{ imported: "default", local: "Alert", kind: "default" }],
//   },
//   {
//     source: "./Chart",
//     specifiers: [{ imported: "Chart", local: "Plot", kind: "named" }],
//   },
//   {
//     source: "./icons",
//     specifiers: [{ imported: "*", local: "Icons", kind: "namespace" }],
//   },
// ]

exports;
// ["title", "helper"]

components;
// ["Alert", "Badge", "Icons.Star"]

Each imports entry is one statement. Specifier kind is default, named, or namespace. exports is the list of exported names (default for export default). components is the unique PascalCase and member JSX names in document order; fragments (<>...</>) are skipped. When MDX is off, or a file has no MDX nodes, these three exports are empty arrays so the module shape stays stable.

import Alert from './Alert'
import { Chart as Plot } from './Chart'
import * as Icons from './icons'

export const title = 'Guide'
export function helper() {}

<Alert />

Hello <Badge /> and <Icons.Star />

Document-level {expression} uses a naive brace / string / comment scan — not a JavaScript parser — so regex literals may confuse boundaries. Unclosed { stays ordinary text. Fences and inline code never become expressions. Hostile source such as { "<script>" } is stored and is not emitted as HTML.

When MDX is on, Markdown between a component's tags is parsed as Markdown and rendered as HTML inside the island wrapper (<h1>, <strong>, lists, fences). Fenced and inline code stay code — a <Alert /> inside a fence is not an island. Nested PascalCase tags become nested islands. Unclosed tags do not swallow the rest of the file. Hostile raw HTML in children such as <script>alert(1)</script> is neutralized (the leading < is escaped) so it cannot execute. Fragments (<>...</>) render their markdown children with no island wrapper.

When MDX is on, a named JSX component becomes an island placeholder in the HTML (data-ox-island="Name"). Its attributes are serialized onto that island — they are not run:

  • quoted strings, boolean attributes, and JSON-literal {42} / {true} / {"a":1} values become JSON-safe props
  • any other {expression} is stored as a source string
  • {...spread} attributes become a spread-source list

The payload is JSON that unicode-escapes <, >, and &, then sits in data-ox-props (HTML-escaped) and in a <script type="application/json"> that the browser does not execute. Hostile source such as {"</script><script>"} or {alert(1)} cannot break out of the payload and is not evaluated. Pages with no components emit no <script> and no island runtime. Framework plugins resolve registered names and document-local imports from the MDX AST and hydrate those islands later. Unregistered names keep the static HTML the renderer already emitted.

Props#

Props use JSX-like syntax. The following forms are recognised:

Syntax Serialized as
prop="text" string
prop={42} number / JSON value
prop={true} boolean
prop={ {"a":1} } object (JSON)
prop boolean true
prop={count + 1} expression source (not evaluated)
{...props} spread source (not evaluated)

Literal props, expression sources, and spreads are serialized together on the island element. Hydration still happens later; this slice only stores the payload.

How islands hydrate#

Each component becomes an island wrapper in the generated HTML — a block-level component renders as a <div data-ox-island="Name" …> and an inline one as a <span data-ox-island="Name" …>. The matching framework runtime mounts the real component into that element on the client. When an adapter has already rendered the component HTML on the server, the island wrapper carries data-ox-ssr="true" so compatible runtimes can hydrate the existing DOM instead.

Hydration timing is controlled by a load strategy (see @ox-content/islands):

Strategy Hydrates…
eager immediately on load (default)
idle during requestIdleCallback (≈200 ms fallback)
visible when the element scrolls into view (IntersectionObserver)
media when a media query matches (matchMedia)

Because the server output is plain HTML, pages render and are readable before (or entirely without) hydration; the island JavaScript is only loaded for the components a page actually uses. On .mdx, that list comes from the AST intersected with the global component map and any resolved document-local imports, so a nested or fragmented tag still hydrates when it is registered or imported by that page.

Islands in a document-props page#

mdxDocumentProps: true renders an .mdx file as a page template: document text and component props come from the host at render time, and the components are real children of the generated page component rather than island placeholders. That is what makes typed build-time props work — and it is also why a host that only server-renders the page has nothing to hydrate.

Mark the components that need to come alive with oxIsland. They keep their build-time props for the server render, and gain the island wrapper the runtime needs:

import Counter from "./Counter.svelte";

# {title}

<Counter initial={initial} oxIsland />

The component still renders on the server, inside the wrapper, so the page is readable without JavaScript and the runtime hydrates the existing DOM rather than mounting a second copy. Everything not marked stays a plain child and ships no JavaScript at all.

Directive Effect
oxIsland Island, hydrated eagerly
oxIsland="idle" Hydrate during requestIdleCallback
oxIsland="visible" Hydrate when the element scrolls into view
oxIsland="media" Hydrate when oxIslandMedia matches
oxIslandMedia="(min-width: 40em)" The query for the media strategy

The strategy has to be a literal — it is chosen when the page is built, not resolved from a document prop.

Starting the runtime#

The whole point of this mode is that the host does not hydrate the page, so the runtime cannot start itself from the page component. The generated module exports it instead, and pages with no islands do not export it at all:

import Page, { hydrateIslands } from "./page.mdx";
import { render } from "svelte/server";

// Server
const html = render(Page, { props: { title: "Example", initial: 1 } }).body;

// Client
const controller = hydrateIslands();
// controller.destroy() when the page goes away

hydrateIslands forwards its argument to initIslands, so the load-strategy options and the controller cleanup behave exactly as they do everywhere else.

Props have to reach the client#

The props a marked component is rendered with are serialised onto its wrapper, so hydration starts from the same values the server used. A prop that cannot survive that trip — a function, a symbol, a cycle — fails the render with a diagnostic naming the prop:

[ox-content-svelte] Island "Counter" in /docs/page.mdx received a function for
prop "onSelect", which cannot be serialised for hydration. Pass a JSON value,
or drop oxIsland to keep the component server-only.

Passing a callback down is the usual cause. Either move the behaviour inside the component, or leave the component server-only.

Static JSX in themes#

Separately from component islands, Ox Content ships a small static JSX runtime (jsx, jsxs, Fragment, renderToString, raw, when, each) used to author themes and layouts that render to HTML strings with no client-side JavaScript. Configure it in tsconfig.json:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@ox-content/vite-plugin"
  }
}

That resolves @ox-content/vite-plugin/jsx-runtime, and @ox-content/vite-plugin/jsx-dev-runtime when jsx is react-jsxdev. Both render to HTML strings; there is no React and no dev-only behavior to opt into.

See Theming for using it to build a custom layout.

See also#

Last updated: