Skip to content

Quartz MDX

“Quartz 4 is great, but I need MDX.”

Quartz 4’s pipeline is Markdown-first; it ships Obsidian-flavored and GFM syntax (callouts, wikilinks, tables, etc.) but does not document MDX support. That’s consistent with community notes that simply adding remark-mdx isn’t enough to enable MDX end-to-end in Quartz’s build.

Quartz: Authoring Content Remark MDX note

Yes: you can fork Quartz and add MDX. Here’s the shortest credible path that fits Quartz’s pipeline.

Quartz Architecture

Quartz parses Markdown → runs remark/rehype → converts HAST to JSX (Preact) → renders to static HTML. MDX needs either (A) MDX nodes to survive into HAST and be evaluated at render time, or (B) a compile step that turns .mdx into a component before emit.

Quartz Architecture @mdx-js/mdx remark-mdx docs

1) “Pass-through MDX” (no separate compile step)

Section titled “1) “Pass-through MDX” (no separate compile step)”

Goal: keep Quartz’s unified pipeline, add remark-mdx, and make HAST→JSX understand MDX nodes.

  • Add a transformer plugin that registers remark-mdx and configures remark-rehype with passThrough: ['mdxjsEsm','mdxFlowExpression','mdxJsxFlowElement','mdxJsxTextElement','mdxTextExpression'] so MDX nodes aren’t dropped.

remark-mdx docs Quartz: Making Plugins

  • Update Quartz’s core parse step (where it calls remark-rehype) to honor that passThrough option from transformer plugins (today it converts mdast→hast without MDX awareness).

Quartz Architecture Quartz: Making Plugins

  • At emit time, provide a createEvaluater to hast-util-to-jsx-runtime so embedded MDX expressions/ESM can be evaluated during server render (static HTML). You also map MDX components via the Components option.

hast-to-jsx README

Tradeoffs: simplest patch footprint; supports JSX/expressions; still static output. Caveat: you’re evaluating arbitrary code at build—sandbox it.

hast-to-jsx README

2) “Compile MDX files” (separate path for .mdx)

Section titled “2) “Compile MDX files” (separate path for .mdx)”

Goal: treat .mdx as first-class: compile to an ESM component with @mdx-js/mdx, then render it with Preact to HTML inside the existing emitter.

  • Extend Quartz’s content glob to include **/*.mdx.

  • Add a transformer that, when a file has .mdx, runs compile() from @mdx-js/mdx (with your remark/rehype plugins), writes the JS to .quartz-cache/mdx/<slug>.mjs, then dynamically import it inside the emitter and render() with preact-render-to-string.

@mdx-js/mdx Quartz Architecture

  • Provide a components map (shortcodes) so authors can import via frontmatter or a local registry.

Tradeoffs: slightly more code; the separation keeps Markdown path untouched and gives you full MDX parity.

  • quartz/build.ts: include .mdx in the glob; thread MDX plugin options into the unified pipeline.

Quartz Architecture

  • quartz/plugins/transformers/ (new): mdx.ts transformer registering remark-mdx and (if you pick path 1) pass-through options.

Quartz: Making Plugins

  • quartz/components/renderPage.tsx (or where HAST→JSX happens): inject createEvaluater and a Components map; or (path 2) add an MDX branch that imports compiled modules and renders them.

Quartz Architecture hast-to-jsx README

  • Do not evaluate untrusted MDX blindly. If you enable expressions/ESM (path 1), wire createEvaluater to a restrictive VM (no fs/net; whitelist exports/imports). Or prefer path 2 and disable ESM in authoring conventions.

hast-to-jsx README @mdx-js/mdx

  • Start with “static MDX” (components without client interactivity). Quartz already emits static HTML via Preact SSR; interactive islands would require client bundles you manually attach (Quartz can bundle “inline” client scripts, but there’s no automatic hydration).

Quartz Architecture

Multiple maintainers note that adding only remark-mdx will not enable MDX—you must either pass MDX nodes through and evaluate them, or compile MDX to JS. Plan accordingly.

MDX note remark-mdx docs

Fork Quartz and start with Strategy 2 (compile .mdx) for clear isolation and easier sandboxing. When stable, you can attempt Strategy 1 to unify the pipeline. I can draft the exact diffs (glob, transformer, emitter shim) and a tiny components registry if you want.

Quartz Architecture @mdx-js/mdx

// quartz/plugins/transformers/mdx.ts — Strategy 2 (compile)
import {QuartzTransformerPlugin} from "../types"
import {compile} from "@mdx-js/mdx"
import * as fs from "node:fs/promises"
import * as path from "node:path"
export const MdxCompile: QuartzTransformerPlugin = () => ({
name: "MdxCompile",
async textTransform(ctx, src) {
// leave raw text unchanged for non-MDX
return src
},
// Hook: after Quartz reads the file, detect .mdx in vfile.data and precompile
markdownPlugins() { return [] },
htmlPlugins() { return [] },
async externalResources(ctx) { return {} },
})
// Pseudocode wiring (keep in your build step):
// if (filepath.endsWith(".mdx")) {
// const out = await compile(src, {remarkPlugins: [...], rehypePlugins: [...]})
// const outPath = path.join(".quartz-cache/mdx", slug + ".mjs")
// await fs.mkdir(path.dirname(outPath), {recursive: true})
// await fs.writeFile(outPath, String(out))
// vfile.data.mdxModulePath = outPath
// }
// quartz/plugins/emitters/contentPage.tsx — MDX branch (sketch)
import {render} from "preact-render-to-string"
import {Fragment, jsx, jsxs} from "preact/jsx-runtime"
import * as path from "node:path"
export const ContentPage = () => ({
name: "ContentPage",
getQuartzComponents() { /* unchanged */ },
async emit(ctx, content, resources, write) {
const pages = []
for (const [tree, file] of content) {
const slug = file.data.slug!
let html: string
if (file.data.mdxModulePath) {
const mod = await import(path.resolve(file.data.mdxModulePath))
const MDXComp = mod.default
html = render(<MDXComp components={{/* shortcodes */}} />)
} else {
html = renderPage(ctx.cfg, slug, { tree, fileData: file.data, /* ... */ })
}
pages.push(await write({ ctx, slug, ext: ".html", content: html }))
}
return pages
}
})
--- a/quartz/build.ts
+++ b/quartz/build.ts
- const files = await glob("content/**/*.md") // Markdown only
+ const files = await glob("content/**/*.{md,mdx}") // Add MDX
// ensure transformers run before emit and expose vfile.data.mdxModulePath
References: Quartz pipeline & plugin interfaces; MDX pass-through & compile/eval details.
[Quartz Architecture](https://quartz.jzhao.xyz/advanced/architecture)
[Quartz: Making Plugins](https://quartz.jzhao.xyz/advanced/making-plugins)
[remark-mdx docs](https://mdxjs.com/packages/remark-mdx/)
[hast-to-jsx README](https://github.com/syntax-tree/hast-util-to-jsx-runtime)
[@mdx-js/mdx](https://mdxjs.com/packages/mdx/)