Type something to search...
How to write blog posts in MDX with Astro?

How to write blog posts in MDX with Astro?

To write blog posts in MDX with Astro, install the official integration with npx astro add mdx, make sure your blog collection's glob() loader matches .mdx files (for example pattern: "**/*.{md,mdx}"), and create posts with an .mdx extension. An MDX post has the same frontmatter as a Markdown post, but its body can also import and use Astro or framework components, embed JavaScript expressions in curly braces, and export values. You render it exactly like Markdown, with render(entry) and the <Content /> component, and you can swap standard elements such as headings or blockquotes for your own components.

MDX is the natural next step when plain Markdown runs out: callouts, embedded demos, charts or interactive widgets inside an article. It also comes with stricter syntax, and Astro 7 changed the processor that compiles it. In this article you'll learn how to set up MDX, how MDX posts fit into a content collection, how to use components, expressions and exports, how to map Markdown elements to custom components, what Astro 7's Sätteri processor means for plugins, and which syntax mistakes break the build. All examples were built with Astro 7.4 and @astrojs/mdx 8.1.

Installing the MDX Integration

The quickest setup is the astro add command, which installs the package and updates your config:

npx astro add mdx

After it runs, your config includes the integration:

// astro.config.mjs
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";

export default defineConfig({
  integrations: [mdx()],
});

To install manually instead, run npm install @astrojs/mdx and add mdx() to integrations yourself. No further configuration is needed to start writing .mdx files. For editor support in VS Code, install the official MDX extension.

Adding MDX Posts to Your Collection

MDX files only become collection entries if the loader's pattern matches them. A pattern of **/*.md silently ignores every .mdx file, so check this first:

// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";

const blog = defineCollection({
  loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    tags: z.array(z.string()).default([]),
  }),
});

export const collections = { blog };

Markdown and MDX posts can now sit side by side in the same folder, validated by the same schema. The file name still determines the ID, so mdx-post.mdx gets the ID mdx-post. If collections are new to you, read what content collections are in Astro first.

Anatomy of an MDX Post

Here's a complete MDX post that uses most of the features covered below:

---
title: "Writing posts in MDX"
description: "An example post that mixes Markdown with Astro components, expressions and exports."
pubDate: 2026-10-20
tags: ["astro", "mdx"]
---
import Callout from "../../components/Callout.astro";
import Blockquote from "../../components/Blockquote.astro";

export const components = { blockquote: Blockquote };
export const year = new Date("2026-10-20").getFullYear();

## Why MDX

You can write normal **Markdown**, then drop in a component:

<Callout type="tip" title="Good to know">
  Components in MDX can contain *Markdown* too.
</Callout>

> This quote renders through a custom component.

This post was written in {year} and is titled "{frontmatter.title}".

From top to bottom:

  • Frontmatter: YAML between --- lines, validated by your collection schema, just like Markdown.
  • Imports: ES module imports, with paths relative to the MDX file.
  • Exports: values available inside the post and to anything that imports the file.
  • Markdown: headings, lists, links and emphasis work as usual.
  • Components: used with JSX syntax, including children.
  • Expressions: JavaScript in curly braces, including the special frontmatter variable.

Using Astro Components

Any .astro component can be imported and used. This callout accepts a type and an optional title, and renders its children through a <slot />:

---
// src/components/Callout.astro
interface Props {
  type?: "note" | "tip" | "warning";
  title?: string;
}

const { type = "note", title } = Astro.props;
---
<aside class={`callout callout-${type}`}>
  {title && <p class="callout-title">{title}</p>}
  <slot />
</aside>

<style>
  .callout { border-left: 4px solid; padding: 0.75rem 1rem; margin: 1.5rem 0; }
  .callout-note { border-color: steelblue; }
  .callout-tip { border-color: seagreen; }
  .callout-warning { border-color: darkorange; }
  .callout-title { font-weight: 700; margin: 0 0 0.25rem; }
</style>

In the built page, the Markdown inside the component was rendered too, and the component's scoped styles came along:

<aside class="callout callout-tip" data-astro-cid-q2ml7llr><p class="callout-title" data-astro-cid-q2ml7llr>Good to know</p><p>Components in MDX can contain <em>Markdown</em> too.</p></aside>

Notice the blank-line rule: the content inside <Callout> sits on its own indented line, so MDX treats it as Markdown and wraps it in a paragraph. Slots are explained in more depth in how to use slots in Astro.

Using Framework Components

React, Vue, Svelte and other framework components work in MDX once their integration is installed. They render to static HTML by default; add a client:* directive when they need to be interactive:

import Counter from "../../components/Counter.jsx";

Here is a live counter:

<Counter client:visible />

See how to use React components in Astro for setting up the integration and choosing a directive.

Expressions and Frontmatter

Curly braces evaluate JavaScript. Inside an MDX file you have access to a frontmatter object with the post's frontmatter, plus anything you've exported or imported:

export const year = new Date("2026-10-20").getFullYear();

This post was written in {year} and is titled "{frontmatter.title}".

That rendered as:

<p>This post was written in 2026 and is titled “Writing posts in MDX”.</p>

The curly quotes come from smart punctuation, which Astro's Markdown processors apply by default.

Keep expressions small. Heavy logic belongs in a component or a utility module, where it can be typed and tested. And remember that expressions run at build time for static sites, so new Date() with no argument gives the build date, not the reader's date.

Mapping Markdown to Custom Components

MDX lets you replace the HTML element produced by a piece of Markdown syntax with your own component. There are two ways to do it.

Exporting components From the Post

Export a components object from the MDX file, mapping element names to components:

import Blockquote from "../../components/Blockquote.astro";

export const components = { blockquote: Blockquote };

> This quote renders through a custom component.
---
// src/components/Blockquote.astro
---
<blockquote class="pull-quote"><slot /></blockquote>

In my test, rendering the entry with render() applied the exported mapping, and the quote came out with the pull-quote class. The MDX docs note that when you import an MDX file directly rather than through a collection, you need to import its components export and pass it back to <Content /> yourself.

Passing components to Content

To apply a mapping to every post, pass a components prop where you render the content:

---
// src/pages/blog/[...id].astro
import { getCollection, render } from "astro:content";
import Heading2 from "../../components/Heading2.astro";

export async function getStaticPaths() {
  const posts = await getCollection("blog");
  return posts.map((post) => ({
    params: { id: post.id },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---
<article>
  <h1>{post.data.title}</h1>
  <Content components={{ h2: Heading2 }} />
</article>
---
// src/components/Heading2.astro
const { id } = Astro.props;
---
<h2 id={id} class="post-heading">
  <a href={`#${id}`}>#</a>
  <slot />
</h2>

The component receives the heading's generated id as a prop, so anchor links keep working:

<h2 id="why-mdx" class="post-heading"><a href="#why-mdx">#</a>Why MDX</h2>

This mapping only affects MDX entries. My .md posts, rendered by the same page, kept their plain <h2> elements. If you need the same transformation for Markdown files, you'll need a Markdown processor plugin instead.

MDX and the Sätteri Processor in Astro 7

Astro 7 replaced the remark and rehype pipeline with Sätteri, a Rust-based Markdown and MDX compiler, as the default processor for both .md and .mdx files. For most posts you won't notice. GitHub-Flavoured Markdown and smart punctuation behave as before, and @astrojs/markdown-remark is no longer installed by default.

Plugins are where it matters:

  • No plugins: nothing to do.
  • Remark or rehype plugins: either port them to Sätteri mdast or hast plugins, or switch back to the unified pipeline by installing @astrojs/markdown-remark and setting markdown.processor: unified().
  • Recma plugins: these only work with the unified processor, passed as unified({ recmaPlugins: [...] }). The old recmaPlugins option on mdx() is deprecated.

MDX inherits your markdown config by default, but it can use its own processor. This keeps Sätteri for .md files and uses unified for .mdx:

// astro.config.mjs
import { defineConfig } from "astro/config";
import { unified } from "@astrojs/markdown-remark";
import mdx from "@astrojs/mdx";

export default defineConfig({
  integrations: [mdx({ processor: unified() })],
});

Set extendMarkdownConfig: false on mdx() if you want MDX to ignore your markdown settings entirely. A worked example of a processor plugin, in both Sätteri and unified versions, is in how to calculate reading time for Astro blog posts.

MDX Syntax Gotchas

MDX is stricter than Markdown because it's parsed as JSX. These are the errors I hit while testing, with the messages Astro printed.

HTML Comments Don't Work

Hello <!-- comment --> world
Unexpected character `!` (U+0021) before name, expected a character that can start a name, such as a letter, `$`, or `_` (note: to create a comment in MDX, use `{/* text */}`) (mdx-jsx:unexpected-character)

Use a JavaScript comment inside braces instead: {/* comment */}. It produces no output.

Void Elements Must Be Self-Closed

<img src="/a.png">
Expected a closing tag for `<img>` (9:1) (mdx-jsx:unexpected-character)

Write <img src="/a.png" />, and the same for <br /> and <hr />. This matches the stricter rules Astro 7's Rust compiler applies to .astro files too.

Curly Braces Are Code

Use { braces } in your config.

This compiled, but the build then failed with ReferenceError: braces is not defined, because MDX evaluated braces as a variable. Escape literal braces as \{ and \}, or put them in inline code.

What Worked Fine

  • A bare less-than sign: Price is 5 < 10 rendered as 5 &lt; 10 without errors in my Sätteri build.
  • class attributes: <div class="box">*hi*</div> rendered as <div class="box"><em>hi</em></div>, Markdown included.
  • Frontmatter: identical to Markdown.

When to Choose MDX Over Markdown

Use Markdown whenUse MDX when
The post is prose, images and codeYou need components inside the post
Non-developers edit the contentAuthors are comfortable with JSX syntax
You want the simplest, most portable filesYou want custom elements per post
You rely on HTML comments or loose HTMLYou can follow JSX rules

Both formats can live in the same collection, so you don't have to choose once for the whole blog. Write most posts in Markdown and reach for MDX when a post really needs it. Note that live content collections can't render MDX, so MDX content must come through build-time collections.

Optimising MDX Builds

If you have many MDX files and builds feel slow, the integration has an optimize option that converts static content to strings ahead of time:

// astro.config.mjs
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";

export default defineConfig({
  integrations: [mdx({ optimize: true })],
});

The docs warn that it may generate unescaped HTML, so check interactive parts afterwards. If you pass custom components through the components prop, list their element names in optimize.ignoreElementNames so they aren't flattened. Mappings exported from the MDX file itself are detected automatically.


FAQ: MDX Blog Posts in Astro

Yes. MDX support comes from the official @astrojs/mdx integration. Run npx astro add mdx, or install the package and add mdx() to the integrations array in your Astro config.

The collection's glob() pattern probably only matches .md files. Change it to a pattern that matches both the md and mdx extensions, as shown in the collection config above, so the loader picks up both formats, then restart the dev server or run astro sync.

HTML comments cause a build error in MDX. Use a JavaScript comment inside curly braces, written as an opening brace, slash and asterisk, your text, then asterisk, slash and a closing brace. It produces no output.

Not with the default Sätteri processor. Either port them to Sätteri mdast or hast plugins, or install @astrojs/markdown-remark and use unified() as the processor, globally or just for MDX through the mdx() processor option.

Yes, after installing the React integration. Import the component at the top of the post and use it like JSX. Add a client directive such as client:visible if it needs to run in the browser.

Yes, for MDX content. Pass a components prop such as h2 mapped to your component to the Content component, or export a components object from the MDX file. Plain Markdown files ignore this mapping.


Conclusion

Writing blog posts in MDX with Astro takes three steps: install @astrojs/mdx, make sure your collection's loader matches .mdx files, and start writing. Your posts keep the same frontmatter and schema as Markdown, and gain imports, components, expressions and exports, along with the ability to swap Markdown elements for your own components through the components prop or an exported components object.

Keep the stricter syntax in mind: no HTML comments, self-closing void elements, and escaped curly braces. In Astro 7, MDX compiles through Sätteri by default, so remark and rehype plugins need either porting or the unified() processor. With those rules understood, MDX becomes a precise tool for the posts that need more than Markdown, while the rest of your blog stays simple.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper