What is an MDX file? Markdown with components, explained
You cloned a docs repo and the pages are .mdx, not .md. Your editor shows Markdown with stray <Callout> tags and import lines at the top, and GitHub renders it half-wrong. MDX is Markdown that can import and render components — one file holding four languages at once. Here is what is actually inside one, with a complete example, what it compiles to, and how to read one in your browser without installing a thing.
What is MDX?
MDX is a document format that adds two things to Markdown: JSX — component tags like <Callout type="warning"> written inline with your prose — and ESM, the JavaScript import and export statements that make those components available. The file extension is .mdx, it is plain UTF-8 text, and it compiles to a JavaScript module rather than to HTML.
It was created by John Otander, Tim Neutkens, Guillermo Rauch and Brent Jackson, and released in 2018, to end a choice documentation authors kept having to make: prose that is pleasant to write, or pages that can show a live, interactive demo. MDX gives you both in one file. MDX 2 (2022) rewrote the compiler on top of micromark and promoted JSX and curly-brace expressions to real syntax instead of passed-through HTML; MDX 3 (2023) is a smaller step on the same design.
If you have written documentation in the last few years, you have probably written MDX without choosing it. It is the page format behind Docusaurus, Next.js, Astro, Storybook docs pages, Gatsby, Nextra and Redwood.
The smallest interesting MDX file is three lines:
import Chart from './Chart'
# Revenue
<Chart data={[3, 7, 12]} />
Everything that isn't a tag or an import is ordinary Markdown, and behaves exactly as it would in a .md file. That is the whole pitch: you pay the JavaScript tax only on the lines that need it.
A complete file, annotated
Here is a realistic docs page using every part of the format. It is a trimmed version of the sample you can open in the viewer from the box above.
--- ← 1. frontmatter (YAML)
title: Shipping your first release
sidebar_position: 3
tags: [guide, releases, ci]
---
import Callout from '@site/src/components/Callout' ← 2. ESM
import { Tabs, TabItem } from '@theme/Tabs'
export const releasedOn = '2026-08-04'
# Shipping your first release ← 3. Markdown
Cutting a release takes one command. Last shipped: {releasedOn}.
<Callout type="info" title="Before you start"> ← 4. JSX
You need push rights and an `NPM_TOKEN` in the repo secrets.
</Callout>
## The one command
<Tabs>
<TabItem value="npm" label="npm">
```bash
npm version minor && git push --follow-tags
```
</TabItem>
</Tabs>
```js
// inside a fence this is sample text, not a real import
import NotReal from './nope'
```
Read that last block carefully, because it is the thing every naive MDX tool gets wrong. The import on the final line is inside a fenced code block, so it is Markdown content — not an ESM statement. Anything that greps for ^import will hallucinate a dependency that doesn't exist, and anything that greps for <Tag> will report components from your code samples.
The four layers in one file
Every byte of an MDX document belongs to exactly one of four layers. Knowing which one is what makes MDX tooling harder than Markdown tooling:
| Layer | Looks like | Where it's allowed |
|---|---|---|
| Frontmatter | a ----fenced YAML block | The very top of the file, once |
| ESM | import X from '…', export const y = … | Column 0 of the document body only |
| JSX | <Callout type="warn">…</Callout> | Anywhere a Markdown block or inline can go |
| Expressions | {2 + 2}, {/* a comment */} | Anywhere, including inside JSX attributes |
| Markdown | everything else | Including inside JSX children |
The layers nest. Markdown can appear inside a JSX element's children, which can contain more JSX, which can contain an expression holding a JavaScript object. That recursion is why MDX needs a real parser and not a regular expression — and why the viewer's COMPONENTS tab exists.
Frontmatter: the metadata block
Most MDX pipelines let you open a file with a ----fenced YAML block. It is not part of the MDX language itself — it comes from a plugin (remark-frontmatter, plus remark-mdx-frontmatter to turn it into exports), and every framework wires it up by default:
---
title: Shipping your first release
description: From tag to changelog.
sidebar_position: 3
draft: false
---
The keys are pure convention — title, tags and draft mean whatever your site generator says they mean. Docusaurus reads sidebar_position; Astro reads whatever your content-collection schema declares. If you're chasing a page that sorts wrong or won't publish, the frontmatter is the first thing to check, and the viewer surfaces it as a labelled block at the top of the PREVIEW tab.
Imports and exports
An MDX file is a JavaScript module, so it can do what modules do. import pulls in components (or data, or styles); export publishes values that the file itself can use and that the importing page can read:
import Callout from '@site/src/components/Callout'
import { Tabs, TabItem } from '@theme/Tabs'
import data from './benchmarks.json'
export const releasedOn = '2026-08-04'
export const meta = { reviewed: true, owner: 'platform' }
Two rules that trip people up. First, these statements must start at column 0 — indent one and it becomes a Markdown paragraph (or, indented four spaces, a code block). Second, they can be anywhere in the document, not just the top; an import halfway down the page is legal and hoisted, though hardly kind to the next reader.
Components: the capital letter that changes everything
This is the single most important rule in MDX, and it is decided entirely by the first character of the tag name:
<div>,<kbd>— lowercase is an intrinsic HTML element. It compiles to a string and renders as itself.<Callout>,<Chart.Line>— capitalised or dotted is a component. It compiles to a variable reference, which must resolve to something.
A component resolves from exactly three places, in order: an import in the file, something the file exports, or the components object supplied at render time by an MDXProvider. Nothing else. Props are JSX props — a quoted string, or any JavaScript inside braces:
<ReleaseTimeline
stages={['tag', 'build', 'sign', 'publish']}
live
caption="What CI does after the tag"
/>
Children are where MDX earns its name: put blank lines around the content and it is parsed as Markdown. Leave them out and it is treated as JSX text, so your **bold** arrives on the page as literal asterisks.
<Callout type="warning">
Publishing is **not** reversible. ← Markdown: renders bold
</Callout>
<Callout type="warning">Publishing is **not** reversible.</Callout>
← JSX text: renders **not**
Expressions
A pair of braces drops you into JavaScript, anywhere in the document:
Last release: {releasedOn}, {new Date().getFullYear()} edition.
{/* This is how you write a comment in MDX. HTML comments are not allowed. */}
<Chart data={data.rows.filter(r => r.visible)} />
The expression is evaluated when the page renders, so it can reference anything in scope: an import, an export from the same file, or a prop. The consequence — and the reason MDX is a build-time format rather than a document format — is that an MDX file is code. You cannot render one you don't trust any more than you'd run a script you don't trust.
What it compiles to
MDX doesn't produce HTML. It produces a JavaScript module exporting a component. That earlier three-line example compiles, roughly, to this:
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from 'react/jsx-runtime'
import Chart from './Chart'
function _createMdxContent(props) {
const _components = { h1: 'h1', ...props.components } // overridable
return _jsxs(_Fragment, { children: [
_jsx(_components.h1, { children: 'Revenue' }), // # Revenue
_jsx(Chart, { data: [3, 7, 12] }) // <Chart …/>
]})
}
export default function MDXContent(props = {}) {
const { wrapper: MDXLayout } = { ...props.components }
return MDXLayout
? _jsx(MDXLayout, { ...props, children: _jsx(_createMdxContent, props) })
: _createMdxContent(props)
}
Three details in there explain most of MDX's behaviour. The _components object is why an MDXProvider can replace every h1 on your site with your own heading component. Chart is a bare identifier — which is why a missing import is a runtime failure, not a compile one. And because the output is a component, an MDX page composes like any other component: props in, JSX out.
The error everybody meets: Expected component `Kbd` to be defined: you likely forgot to import, pass, or provide it. Nothing checks that a capitalised tag resolves until the compiled module is rendered — typically in the middle of a build of two thousand pages, with a stack trace that names no file. The viewer's COMPONENTS tab lists every tag in a file against its imports and exports up front, so you find the one you forgot before CI does.
MDX vs Markdown vs HTML
| Aspect | MDX | Markdown | HTML |
|---|---|---|---|
| Extension | .mdx | .md | .html |
| Components | Yes — real JSX | No | Web components only |
| Needs a build step | Yes | No | No |
| Renders on GitHub | Partially — tags leak | Yes | As source |
| Compiles to | A JS module | HTML | Itself |
| Safe to render untrusted | No — it's code | With sanitising | With sanitising |
| Raw HTML in the file | Parsed as JSX (className) | Passed through (class) | n/a |
The row that matters most in practice is the last one. In a .md file, HTML is opaque text handed to the browser. In MDX there is no raw HTML — what looks like HTML is JSX, and it plays by JSX rules. That single difference is behind most of the surprises below. If your file has no components in it, the Markdown viewer is the simpler tool; if it does, use the MDX one.
The mistakes everybody makes
- A stray
<or{in prose is a syntax error. Writinguse <user> hereorthe {id} fieldmakes the compiler try to parse a tag or an expression. Escape them (\<,\{) or wrap them in backticks. <!-- HTML comments -->don't work. Since MDX 2 there is no raw HTML to hold them. Use{/* … */}.classandstyleare JSX attributes.class="lead"becomesclassName="lead", andstyle="color: red"becomesstyle={{ color: 'red' }}— an object, hence the double braces.- Every tag must be closed. A lone
<br>or<img src="…">is fine in Markdown and fatal in MDX; write<br />and<img src="…" />. - Missing blank lines swallow your Markdown inside component children, as shown above. It is the number-one cause of "why is my bold text showing asterisks".
- Lowercase component names silently render as HTML.
<callout>doesn't error — it emits an unknown HTML element that the browser ignores, so the content just quietly disappears. - Your
.mdfiles may be MDX too. Docusaurus v3 compiles.mdthrough the MDX compiler by default, which is why a v2→v3 upgrade suddenly fails on a plain Markdown file that has contained a stray<happily for years.
Opening an MDX file without a build step
The awkward part of MDX is that reading one properly normally means running a toolchain: install the framework, resolve every component, start a dev server. If all you want is to see what a file says and what it depends on, that is a lot of ceremony — and it's impossible for a file whose components live in a repo you don't have.
The OmniViewer MDX toolkit takes the other approach: parse the document, and draw every component you can't resolve as a labelled placeholder card carrying its props and its rendered children. You see the prose properly formatted, and you see exactly where the components sit, without a single one of them being installed — or executed. Nothing is uploaded, and no JavaScript from the file ever runs.
- PREVIEW — the document rendered, components drawn as placeholder cards with their props.
- COMPONENTS — every tag resolved against the file's imports and exports: imported, locally defined, plain HTML, or nowhere. Plus unused imports, exports and frontmatter.
- FORMATTED — the source tidied, with frontmatter, ESM, JSX and code fences reproduced byte-for-byte, because reflowing them would change what compiles.
- TOC — the heading outline; click to jump.
- STATS — words and reading time, plus the MDX-specific shape: components, expressions, imports, JSX nesting depth, most-used components.
The component audit streams the whole file rather than a bounded prefix, so it holds up on documents far larger than memory — a generated API reference or a decade of concatenated changelogs. That mechanism, and the resumable scanner behind it, is the subject of the companion piece: how to audit a 20 GB MDX file.
.md: preview, formatted source, HTML output and contents.
Audit a 20 GB MDX file →Why the compiler can't scale, and the resumable scanner that can — in real code.
OmniViewer opens every file format in your browser — Markdown, JSON, YAML, JavaScript, CSV, HTML and more — powered by the same windowed engine as fastjsonviewer.com and hugecsv.com. MDX is one of the formats with dedicated tooling.