MDX in Astro
MDX is Markdown with superpowers: it lets you write prose using familiar Markdown syntax while dropping in JSX expressions and interactive components right inside the content. In Astro, MDX is powered by the official @astrojs/mdx integration, which extends .mdx files so they can import .astro, React, Vue, Svelte, or Solid components and render them alongside your text. This is the bridge between content-authoring ergonomics and the full power of Astro’s component model — including islands and client:* directives — without sacrificing the zero-JS-by-default output you get from plain Markdown.
Installing the MDX integration
The fastest way to add MDX is the astro add command, which installs the package and wires it into your config automatically.
npx astro add mdx
If you prefer to do it manually, install the package and register it in astro.config.mjs:
npm install @astrojs/mdx
// astro.config.mjs
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
export default defineConfig({
integrations: [mdx()],
});
Once registered, any .mdx file in src/pages/ becomes a route, and .mdx files inside content collections are picked up automatically.
Tip: The MDX integration builds on top of Astro’s built-in Markdown support. Any
markdownconfig you set (such asremarkPluginsorshikiConfig) is inherited by MDX, so you don’t have to configure syntax highlighting twice.
Writing your first MDX page
MDX files start with the same YAML frontmatter as Markdown, then mix prose with JSX. You import components at the top of the file — after the frontmatter — and use them like HTML tags.
---
title: "Release notes"
layout: ../../layouts/DocsLayout.astro
---
import Callout from "../../components/Callout.astro";
import Counter from "../../components/Counter.jsx";
# {frontmatter.title}
Welcome to the latest release. Here is a heads-up:
<Callout type="warning">
This API is **experimental** and may change before the stable release.
</Callout>
Try the interactive demo below — it ships JavaScript only for this island:
<Counter client:visible />
Notice three things: the frontmatter is exposed as a frontmatter object you can interpolate with {...}; Markdown syntax (like **bold**) still works inside component children; and the Counter island hydrates lazily via client:visible.
JSX expressions and variables
Anything inside curly braces is evaluated as a JavaScript expression. You can define variables, map over arrays, and render dynamic values inline.
---
title: "Pricing"
---
export const plans = ["Free", "Pro", "Enterprise"];
# Available plans
We currently offer **{plans.length}** tiers:
<ul>
{plans.map((plan) => (
<li key={plan}>{plan}</li>
))}
</ul>
Use export const (not plain const) for values you want available throughout the MDX module. Exported identifiers also become part of the module’s exports, so a layout or importing page can read them.
Hydrating components with client directives
Imported framework components are static HTML by default — exactly like in .astro files. To make a component interactive in the browser, add a client:* directive. This is the islands architecture at work: only the directives you opt into ship JavaScript.
| Directive | When it hydrates | Typical use |
|---|---|---|
client:load | Immediately on page load | Above-the-fold widgets |
client:idle | When the browser is idle | Lower-priority interactivity |
client:visible | When scrolled into view | Below-the-fold components |
client:media | When a media query matches | Responsive-only features |
client:only | Client-side only, no SSR | Components that need the DOM |
import Chart from "../../components/Chart.tsx";
<Chart client:visible data={[1, 2, 3, 5, 8]} />
Combining MDX with content collections
MDX shines in content collections, where you keep schema-validated frontmatter in src/content/ and render the body with the <Content /> component returned by the entry.
// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/*.mdx", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
publishYear: z.string(),
}),
});
export const collections = { blog };
---
// src/pages/blog/[...slug].astro
import { getCollection, render } from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<h1>{post.data.title}</h1>
<article>
<Content />
</article>
Because the schema validates publishYear as a string, a value like "2015" stays a string and never gets misparsed — a common gotcha when authors leave numbers unquoted.
Customizing components with the components prop
When rendering MDX, you can override the default HTML elements — swapping every <h2> or <a> for your own component — by passing a components prop to <Content />.
---
import { render } from "astro:content";
import Heading from "../components/Heading.astro";
import Link from "../components/Link.astro";
const { Content } = await render(post);
---
<Content components={{ h2: Heading, a: Link }} />
Output:
Every level-2 heading in the MDX body now renders as <Heading>,
and every Markdown link renders as your custom <Link> component.
Best practices
- Reach for MDX only when a page genuinely needs components or expressions; plain Markdown stays simpler and faster to author.
- Import components at the top of the file, immediately after the frontmatter, to keep modules readable.
- Default to the lightest hydration directive that works — prefer
client:visibleorclient:idleoverclient:loadto minimize shipped JavaScript. - Validate frontmatter with a content collection schema, and always quote numeric-looking values like
"2015"to avoid type surprises. - Use
export constfor shared values so layouts and importing pages can read them. - Override elements via the
componentsprop instead of hard-coding styles, keeping content portable across themes.