MDX & Components#
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.mdxfiles. Withmdx: true/ParserOptions.mdx, you can enable the same path for every configured extension. The Rust parser turns PascalCase and member-name tags intoMdxJsxFlowElement/MdxJsxTextElementnodes (self-closing or open/close, with literal, boolean,{expr}, and spread attributes), turns file-levelimport/exportintoMdxjsEsmnodes, and turns document-level{foo}/Hello {name}intoMdxFlowExpression/MdxTextExpression. Fragments (<>...</>), JSX comments, and{expression}children are stored as AST source. Nothing is evaluated..mdstays 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.mdxfiles (or whenmdx: true), the React/Vue/Svelte/Solid plugins walk the MDX AST — or the rendereddata-ox-islandnames — and import hydration modules for names in the globalcomponentsmap or a relativeimportresolved from that document. Nested JSX, expression attributes, and fragments are visible to that walk. Unregistered JSX without a matching import stays static HTML. Plain.mdstill 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 />) becomedata-ox-islandplaceholders. Props are serialized; nothing is hydrated until a React / Vue / Svelte / Solid plugin is present. - Module-level
import/exportbecomeMdxjsEsmnodes. 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.