· astro / mdx / content-collections

How to Add MDX to Astro 5 Content Collections

Step-by-step guide to installing @astrojs/mdx and wiring it into Astro 5 Content Layer API — typed frontmatter, component imports, and every pitfall documented.

By

972 words · 5 min read

If you want to embed interactive React components in your Astro blog posts, you need MDX. Here’s the exact Astro 5 setup — including the Content Layer API config that replaces the old v2/v4 approach.

Who this is for

Developers running Astro 5 with the Content Layer API who want to embed components inside markdown articles. If you are still on Astro v2 or v3, you need to migrate src/content/config.ts first — see the Astro v5 upgrade guide before continuing. Readers already on Astro 6 can follow the same steps below; see our Astro 6 review for what else changed in that release.

What we tested

  • Astro: 5.18.2
  • @astrojs/mdx: 4.3.14
  • Node: 20.x, macOS

Step 1: Install the MDX integration

The fastest path is the Astro CLI:

npx astro add mdx

This installs @astrojs/mdx@^4 and injects the integration into astro.config.mjs automatically.

Manual install if you prefer explicit control:

npm install @astrojs/mdx@^4
# or: pnpm add @astrojs/mdx@^4
# or: bun add @astrojs/mdx@^4

Then add it to your config:

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

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

Failure mode: if astro.config.mjs already has a syntax error when you run npx astro add mdx, the CLI will appear to succeed but skip injecting the integration. Open the file afterward and confirm mdx() appears in integrations.


Step 2: Configure src/content.config.ts

Astro 5 moved the collection config from src/content/config.ts (the v2/v4 path) to src/content.config.ts at the root of src/. The collections now require a loader: property — omitting it produces a cryptic Zod validation error at build time.

// 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}',  // accepts both md and mdx
  }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    draft: z.boolean().default(false),
  }),
});

export const collections = { blog };

z.coerce.date() matters here: YAML frontmatter dates arrive as strings, and coerce handles the string-to-Date conversion without a custom transform.

Failure mode: keeping the old src/content/config.ts path causes Astro to silently ignore your schema — the build succeeds, but frontmatter is untyped.


Step 3: Write an .mdx file

Drop an .mdx file anywhere under the glob path and import components directly in the body:

---
title: "Hello MDX"
description: "A post with an inline callout component."
pubDate: 2025-06-01
---
import Callout from '../../components/Callout.astro';

This post embeds a component inline:

<Callout type="warning">
  This feature is currently in beta.
</Callout>

Continue reading...

For stability, use path aliases defined in tsconfig.json (@/components/Callout.astro) rather than relative imports — relative paths break when you move the article to a subdirectory.


Step 4: Render in [...slug].astro

Two Astro v5 changes are easy to miss here: render() is now a standalone import from astro:content (not a method on the post object), and the dynamic segment should use post.id rather than post.slug.

---
// src/pages/blog/[...slug].astro
import { getCollection, render } from 'astro:content';
import Callout from '../../components/Callout.astro';

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

const { post } = Astro.props;
const { Content } = await render(post);
---

<html>
  <body>
    <h1>{post.data.title}</h1>
    <!-- inject Callout so every MDX file can use it without an explicit import -->
    <Content components={{ Callout }} />
  </body>
</html>

Passing components to <Content /> is optional — it maps a component name to an implementation so MDX files can reference <Callout> without importing it explicitly. Useful when you have a design-system component every article uses.


Common pitfalls

  • Config path moved: src/content/config.tssrc/content.config.ts (root of src/, not inside content/). Old path is silently ignored — no build error, no type safety.
  • slug renamed to id: post.slug is undefined in Astro v5. Use post.id everywhere, including getStaticPaths().
  • render() is a standalone import: await post.render() (v4) → import { render } from 'astro:content'; await render(post) (v5).
  • HTML comments throw: MDX is JSX. <!-- comment --> is a parse error. Use {/* comment */} instead.
  • Underscore files are no longer auto-excluded: _draft.mdx was silently ignored in v4. In v5 it is included. Filter explicitly with draft: true in frontmatter and getCollection('blog', ({ data }) => !data.draft).
  • Remark plugin ordering: @astrojs/mdx inherits markdown.remarkPlugins by default. If a plugin assumes it runs last, it may see unprocessed MDX nodes. Set extendMarkdownConfig: false in the MDX integration options and declare all plugins explicitly.

Cloudflare Pages

MDX processing is entirely build-time. When you run astro build, every .mdx file is compiled to static HTML and JavaScript before deployment. Cloudflare Pages serves the output — no extra adapter config required for MDX.

SSR with @astrojs/cloudflare is unaffected: MDX collections are always prerendered regardless of adapter. If your build completes locally with astro build, Cloudflare Pages will serve it correctly. For the full Cloudflare Pages deployment walkthrough, see How to deploy an Astro site on Cloudflare Pages.


Verdict

Add MDX when your articles embed interactive UI — code playgrounds, live demos, or design-system components like callouts and warnings. If articles are pure prose, plain .md builds faster, is easier to hand off to non-developer authors, and is more portable to other frameworks.

MDX adds a JSX compilation step per file. On 50–100 posts, build overhead is not a bottleneck in practice. On 1,000+ files, enable optimize: true in the MDX integration config — the Astro docs flag this as the recommended option for large collections.

If this tutorial is part of building a multilingual site, the next natural step is How to build a multilingual Astro site.