MDX extends Markdown with JSX — you can import and render React components inline within prose content. import { Chart } from "./chart" at the top of an MDX file makes <Chart /> available anywhere in the document. Custom components override default HTML elements: pass a components map to replace h1, code, a, and blockquote with styled versions. next-mdx-remote serializes MDX server-side in Next.js App Router and renders it on the client. Astro’s @astrojs/mdx integration processes MDX with Astro’s existing Vite pipeline. Contentlayer generates type-safe document collections from MDX files with frontmatter validation. rehype-pretty-code adds syntax-highlighted code blocks using Shiki. Custom remark plugins transform the AST before rendering. Claude Code generates MDX page configurations, component maps, content type definitions, rehype/remark plugin setups, and the Next.js and Astro integration code for documentation and blog systems.
CLAUDE.md for MDX
## MDX Stack
- Versions: @mdx-js/react >= 3.0, next-mdx-remote >= 5.0 for Next.js
- Astro: @astrojs/mdx >= 4.0 — native MDX support in Astro
- Contentlayer: contentlayer2 >= 0.5 — type-safe content pipeline
- Plugins: rehype-pretty-code (syntax highlighting), remark-gfm (tables/strikethrough)
- Components: MDXProvider with components prop OR serialize({ components }) in next-mdx-remote
- Frontmatter: YAML between --- delimiters — gray-matter for parsing
- Images: next/image or Astro Image — replace <img> in components map
Next.js App Router with next-mdx-remote
// lib/mdx.ts — MDX processing pipeline
import { compileMDX } from "next-mdx-remote/rsc"
import remarkGfm from "remark-gfm"
import rehypePrettyCode from "rehype-pretty-code"
import rehypeSlug from "rehype-slug"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
import { readFile, readdir } from "fs/promises"
import path from "path"
import matter from "gray-matter"
// Custom components to inject into MDX
import { Callout } from "@/components/mdx/Callout"
import { CodeBlock } from "@/components/mdx/CodeBlock"
import { Video } from "@/components/mdx/Video"
import { Tweet } from "@/components/mdx/Tweet"
const MDX_COMPONENTS = {
Callout,
Video,
Tweet,
// Override HTML elements
pre: CodeBlock,
a: ({ href, children, ...props }: React.ComponentPropsWithRef<"a">) => (
<a
href={href}
target={href?.startsWith("http") ? "_blank" : undefined}
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
className="text-blue-600 hover:underline"
{...props}
>
{children}
</a>
),
img: ({ src, alt, ...props }: React.ComponentPropsWithRef<"img">) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt ?? ""} className="rounded-lg my-6 w-full" {...props} />
),
}
const CONTENT_DIR = path.join(process.cwd(), "content")
export interface PostFrontmatter {
title: string
description: string
publishedAt: string
author: string
tags: string[]
featured?: boolean
}
export async function getPost(slug: string) {
const filePath = path.join(CONTENT_DIR, "posts", `${slug}.mdx`)
const source = await readFile(filePath, "utf-8")
const { content, data } = matter(source)
const frontmatter = data as PostFrontmatter
const { content: rendered } = await compileMDX({
source: content,
components: MDX_COMPONENTS,
options: {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{ behavior: "wrap", properties: { className: ["anchor"] } },
],
[
rehypePrettyCode,
{
theme: { dark: "github-dark", light: "github-light" },
keepBackground: false,
// Add line numbers
onVisitLine(node: any) {
if (node.children.length === 0) {
node.children = [{ type: "text", value: " " }]
}
},
},
],
],
},
},
})
return { frontmatter, slug, content: rendered }
}
export async function getAllPosts() {
const dir = path.join(CONTENT_DIR, "posts")
const files = await readdir(dir)
const posts = await Promise.all(
files
.filter(f => f.endsWith(".mdx"))
.map(async f => {
const slug = f.replace(/\.mdx$/, "")
const source = await readFile(path.join(dir, f), "utf-8")
const { data } = matter(source)
return { slug, frontmatter: data as PostFrontmatter }
})
)
return posts.sort(
(a, b) =>
new Date(b.frontmatter.publishedAt).getTime() -
new Date(a.frontmatter.publishedAt).getTime()
)
}
// app/blog/[slug]/page.tsx — Next.js MDX page
import { getPost, getAllPosts } from "@/lib/mdx"
import { notFound } from "next/navigation"
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map(p => ({ slug: p.slug }))
}
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug).catch(() => null)
if (!post) notFound()
return (
<article className="prose lg:prose-xl mx-auto py-12 px-4">
<h1>{post.frontmatter.title}</h1>
<p className="text-muted-foreground">{post.frontmatter.description}</p>
<div className="mt-8">{post.content}</div>
</article>
)
}
MDX Components
// components/mdx/Callout.tsx — custom MDX component
import { cn } from "@/lib/utils"
import { Info, AlertTriangle, CheckCircle, XCircle } from "lucide-react"
type CalloutType = "info" | "warning" | "success" | "error"
const calloutConfig: Record<CalloutType, {
icon: React.ElementType
className: string
iconClassName: string
}> = {
info: {
icon: Info,
className: "bg-blue-50 border-blue-200 text-blue-900",
iconClassName: "text-blue-500",
},
warning: {
icon: AlertTriangle,
className: "bg-amber-50 border-amber-200 text-amber-900",
iconClassName: "text-amber-500",
},
success: {
icon: CheckCircle,
className: "bg-green-50 border-green-200 text-green-900",
iconClassName: "text-green-500",
},
error: {
icon: XCircle,
className: "bg-red-50 border-red-200 text-red-900",
iconClassName: "text-red-500",
},
}
export function Callout({
type = "info",
children,
}: {
type?: CalloutType
children: React.ReactNode
}) {
const { icon: Icon, className, iconClassName } = calloutConfig[type]
return (
<div className={cn("flex gap-3 rounded-lg border p-4 my-6", className)}>
<Icon className={cn("h-5 w-5 shrink-0 mt-0.5", iconClassName)} />
<div className="text-sm leading-relaxed [&>p]:m-0">{children}</div>
</div>
)
}
// components/mdx/CodeBlock.tsx — syntax highlighted code block wrapper
"use client"
import { useState } from "react"
import { Check, Copy } from "lucide-react"
import { cn } from "@/lib/utils"
export function CodeBlock({
children,
className,
...props
}: React.ComponentPropsWithoutRef<"pre">) {
const [copied, setCopied] = useState(false)
function copyCode() {
const code = (children as React.ReactElement)?.props?.children as string
navigator.clipboard.writeText(String(code).trim())
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="relative group my-6">
<button
onClick={copyCode}
className={cn(
"absolute right-3 top-3 opacity-0 group-hover:opacity-100 transition-opacity",
"rounded-md bg-white/10 p-1.5 text-gray-400 hover:text-white"
)}
aria-label="Copy code"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</button>
<pre className={cn("overflow-x-auto rounded-lg p-4", className)} {...props}>
{children}
</pre>
</div>
)
}
Astro MDX Integration
// astro.config.ts — Astro with MDX
import { defineConfig } from "astro/config"
import mdx from "@astrojs/mdx"
import react from "@astrojs/react"
import rehypePrettyCode from "rehype-pretty-code"
import remarkGfm from "remark-gfm"
export default defineConfig({
integrations: [
mdx({
remarkPlugins: [remarkGfm],
rehypePlugins: [
[rehypePrettyCode, { theme: "github-dark" }],
],
// Globally inject components into MDX without importing
components: {
Callout: "./src/components/mdx/Callout.astro",
},
}),
react(),
],
})
---
// content/posts/my-post.mdx — sample MDX with frontmatter
title: "Getting Started"
description: "A complete guide to getting started"
publishedAt: "2024-01-15"
author: "Jane Smith"
tags: ["tutorial", "beginner"]
---
import { Chart } from "@/components/Chart"
import { DataTable } from "@/components/DataTable"
# Getting Started
This is a paragraph with **bold** and _italic_ text.
<Callout type="info">
This component is available globally in Astro MDX — no import needed.
</Callout>
## Interactive Data
<Chart data={[{ month: "Jan", revenue: 4200 }, { month: "Feb", revenue: 5100 }]} />
## Code Example
```typescript
const greeting = "Hello, MDX!"
console.log(greeting)
## Custom Remark Plugin
```typescript
// lib/remark-reading-time.ts — custom remark plugin for reading time
import { visit } from "unist-util-visit"
import type { Root, Text } from "mdast"
export function remarkReadingTime() {
return (tree: Root, file: any) => {
let wordCount = 0
visit(tree, "text", (node: Text) => {
wordCount += node.value.split(/\s+/).filter(Boolean).length
})
const readingTimeMinutes = Math.max(1, Math.round(wordCount / 200))
file.data.astro = file.data.astro ?? {}
file.data.astro.frontmatter = {
...file.data.astro.frontmatter,
readingTime: `${readingTimeMinutes} min read`,
wordCount,
}
}
}
For the Contentlayer type-safe content pipeline that validates MDX frontmatter against TypeScript types and generates typed document collections without manual gray-matter parsing, the Next.js App Router guide covers Contentlayer integration. For the Keystatic CMS alternative that stores MDX content as files in git with a built-in Studio UI for content editors — better when non-developers need to edit content without touching raw MDX files, the file-based approach keeps all content versioned in git. The Claude Skills 360 bundle includes MDX skill sets covering component maps, rehype plugins, and Next.js integration. Start with the free tier to try MDX configuration generation.