MDX
- Category: Core
An .mdx file is a normal point. The top is the usual ESM head — imports and
an export const … = … point chain; the bottom is a Markdown body. The chain
renders that compiled body through a component named MDXContent, into which it
spreads the page's render props. So you write the page chain you already know,
then keep typing prose under it.
import { generalLayout } from '@/layouts/general'
export const page = generalLayout
.lets('page', 'about', '/about')
.head('About')
.page((props) => (
<div className="prose">
<MDXContent {...props} />
</div>
))
# About My App
My App is the best!/about now renders that Markdown. The chain is an ordinary page; the
only new thing is <MDXContent {...props} /> — the bridge from the chain to the
body. The rest of this page shows where each piece comes from.
The shape of an .mdx point
Two halves, in this order:
import { Link } from '@/lib/navigation' // 1. ESM head: imports
import { generalLayout } from '@/layouts/general'
export const page = generalLayout // 2. the point chain (a page here)
.lets('page', 'about', '/about')
.head('About')
.page((props) => (
<div className="prose">
<MDXContent {...props} /> // renders the body below
</div>
))
# About IdeaNick // 3. the Markdown body
IdeaNick is a platform for creating and sharing ideas.The chain is identical to a .tsx page —
.lets(...).loader(...).head(...).page(...). The .page render fn receives
props and forwards them with <MDXContent {...props} />. Everything after the
chain (from the first # heading down) is the body, compiled to the component
you just rendered.
Most apps wrap the body in a typography class —
<div className="prose">(Tailwind Typography) above, or a<Prose>component — so plain Markdown gets readable styling. That wrapper is yours; Point0 adds none.
MDXContent — where it comes from
MDXContent is not a Point0 symbol. It is the default export the MDX
compiler emits for every .mdx module. It is in scope inside the chain because
*.mdx is declared as a module whose default export is MDXContent:
// src/types/global.d.ts
declare module '*.mdx' {
import type { ComponentType } from 'react'
const MDXContent: ComponentType<any>
export default MDXContent
export const page: unknown
export const frontmatter: Record<string, unknown>
}Three things an .mdx module is declared to export: default (= MDXContent),
page (your chain), and frontmatter (see below).
create-point0-app and every example ship this *.mdx block; a hand-rolled app
needs it too, or the JSX scope won't know MDXContent.
Props reach the body through {...props}
The body can read the page's props directly — but only because the render fn
spread them into MDXContent. The compiled MDXContent is a component
(function MDXContent(props)), and the body references that same props:
export const page = generalLayout
.lets('page', 'about', '/about')
.loader(async () => {
const lastIdea = await prisma.idea.findFirst({ orderBy: { id: 'desc' } })
if (!lastIdea) throw new AppError('Last Idea Not Found :–(')
return { lastIdea: { id: lastIdea.id, title: lastIdea.title } } // becomes props.data
})
.head('About')
.page((props) => (
<div className="prose">
<MDXContent {...props} />{' '}
{/* without this spread, props.data below is undefined */}
</div>
))
# About IdeaNick
Fresh idea: <Link route="ideaView"
input={{ id: props.data.lastIdea.id }}>{props.data.lastIdea.title}</Link>The body mixes Markdown with JSX freely — <Link>, props.data.lastIdea.*, any
imported component. props.data is the loader's return, exactly as in a .tsx
page; the full prop list (data, params, search, props, …) is on the
page page. Forget the {...props} spread and the body still compiles,
but props inside it is empty.
Three extensions, one pipeline
.md, .mdx, and .mdc all compile the same way — the compiler detects them
by extension and precompiles each into a JS program before Babel runs (raw
Markdown isn't valid Babel syntax). After that the output is a normal module:
every Point0 source transform applies to it (see
below).
// detection, in the compiler
;/\.(md|mdx|mdc)$/.test(path)One pipeline, two intended uses:
.mdx— a page. You write a point chain plus a Markdown body and render it through<MDXContent {...props} />, exactly as above. This is the case the rest of this page describes..md/.mdc— plain Markdown youimportfor its content. Since the compiled module's default export is theMDXContentcomponent and a YAML block becomesexport const frontmatter, a plainimport Article, { frontmatter } from './post.md'hands you both the rendered body and its metadata, to use however you like — no point chain required.
The extensions are not enforced apart in code: all three run through the same
compileSync call and produce the same module shape (default = MDXContent,
plus any page / frontmatter you export). The split above is convention, not
a compiler check — nothing stops a .md from carrying a page chain or an
.mdx from being imported for its body.
.md parses as MDX too (JSX-aware) unless you set format: 'md' in config
(plain CommonMark — see Config). The page is called "MDX" but
.md/.mdc route through the same code.
Frontmatter
A YAML frontmatter block compiles to an export const frontmatter:
---
title: Privacy Policy
updated: 2025-01-01
---
# Privacy Policy// the compiled module also exports:
export const frontmatter = { title: 'Privacy Policy', updated: '2025-01-01' }The two frontmatter remark plugins are always on (remark-frontmatter +
remark-mdx-frontmatter named frontmatter), so no config is needed for this.
It's a plain module export — Point0 does not auto-feed it into .head() or
anything else; import and use it yourself.
Discovery: include mdx in pointsGlob
The compiler treats .mdx as a point, but the engine only finds it if
pointsGlob matches it. Add the extension:
export const engine = Engine.create({
pointsGlob: '**/*.{ts,tsx,mdx}', // without mdx, the .mdx page is never discovered
// ...
})This is the one easy-to-miss setup step. create-point0-app already ships
'**/*.{ts,tsx,mdx}'; if you copy a leaner glob, widen it. Editing an .mdx
body in dev hot-swaps without a server restart — the dev watcher follows the
real import graph, not the glob, so a reachable .mdx page updates in place.
Point0 transforms run on the body
Because the MDX output is re-parsed as a normal module, the standard Point0 transforms apply inside the Markdown body — it is first-class source, not inert text:
# Status
{env.side.is.client ? 'client' : 'server'}
{/* env tree-shaken per side at build */}
<ClientOnly>
<span>browser-only content</span> {/* stripped from the server render */}
</ClientOnly>So env shaking and server-side <ClientOnly> stripping work in
the body exactly as in a .tsx file. The same rule as in .tsx holds: the
browser bundle is public, so don't put secret content in Markdown expecting it
to stay private — server-only code is stripped at compile time, but anything
that survives into the render output ships to the client. Gate access in
.with, not on render output.
Dev vs build runtime
You don't configure this, but it's worth knowing: the compiler forces the MDX
development flag from the build state — react/jsx-dev-runtime while
developing, react/jsx-runtime in a production build. A user markdown config
can override most fields, but not development; build state always wins.
React must be the runtime either way (the compiler emits jsx-runtime calls,
not raw JSX).
Config
App authors tune MDX through the engine's compiler.markdown option. It's the
full @mdx-js/mdx CompileOptions, except the three plugin arrays also accept
string paths and [path, options] tuples (not just live function refs):
export const engine = Engine.create({
// ...
server: {
compiler: {
markdown: {
format: 'md', // parse .md/.mdx as plain CommonMark instead of MDX
remarkPlugins: [
'remark-gfm', // a string path — require()d for you
['remark-toc', { tight: true }], // a [path, options] tuple
],
},
},
},
})Merge rules, from highest precedence to lowest:
- Scalar fields (
format,outputFormat, …) are last-write-wins: a server- or client-specificmarkdownoverrides the general one. - Plugin arrays are concatenated, never replaced — the framework defaults (the two frontmatter plugins) come first, then general config, then specific. You can only add plugins, not drop the defaults.
developmentis not overridable (see above).
markdown exists at the general, server, and client compiler levels, so you can
run a different plugin set per side.
All three plugin arrays — remarkPlugins, rehypePlugins, recmaPlugins — are
merged and concatenated the same way, and accept the string-path / tuple forms.
Reference
Default compile options
The compiler's standalone defaults (before your markdown config and the forced
development):
| Option | Default | Note |
|---|---|---|
jsx | false | emits react/jsx-runtime calls, not raw JSX |
outputFormat | 'program' | full ESM module with the MDXContent default export |
format | 'mdx' | JSX-aware; set 'md' for plain CommonMark |
development | forced !built | dev-runtime in dev, prod-runtime in a build — not overridable |
remarkPlugins | remarkFrontmatter, remarkMdxFrontmatter({ name: 'frontmatter' }) | always prepended |
Module exports of an .mdx point
| Export | What it is |
|---|---|
default | MDXContent — the compiled Markdown body component |
page | your point chain (export const page = …) |
frontmatter | the YAML frontmatter object, or absent if no block |
Failure modes
- Empty compile output is a hard error:
Failed to compile MDX/MDC file <abs>, empty content. - No
{...props}spread intoMDXContent→ the body'spropsis empty (no type error, just missing data). pointsGlobwithoutmdx→ the page is never discovered.
Passing through to @mdx-js/mdx
A couple of things the underlying MDX compiler allows but Point0 does not wire itself:
components/wrapperonMDXContent. The compiledMDXContentaccepts the standard MDXcomponentsmap (andwrapper) to override how Markdown elements render. Reaching for it means relying on@mdx-js/mdxbehavior directly.
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️