Skip to content
Astro as markdown 4 min read

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 markdown config you set (such as remarkPlugins or shikiConfig) 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.

DirectiveWhen it hydratesTypical use
client:loadImmediately on page loadAbove-the-fold widgets
client:idleWhen the browser is idleLower-priority interactivity
client:visibleWhen scrolled into viewBelow-the-fold components
client:mediaWhen a media query matchesResponsive-only features
client:onlyClient-side only, no SSRComponents 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:visible or client:idle over client:load to minimize shipped JavaScript.
  • Validate frontmatter with a content collection schema, and always quote numeric-looking values like "2015" to avoid type surprises.
  • Use export const for shared values so layouts and importing pages can read them.
  • Override elements via the components prop instead of hard-coding styles, keeping content portable across themes.
Last updated June 14, 2026
Was this helpful?