Claude Code with MDX: Components, Bundlers, RSC
Why MDX without CLAUDE.md mixes server and client component boundaries
MDX combines Markdown and JSX in a single file. You write prose, and anywhere you need a rendered component you drop <MyComponent /> directly into the text. The bundler transforms that file into a JavaScript module that Next.js, Astro, or Vite can import and render. The power is real. The confusion starts immediately when Claude Code gets involved.
Without explicit constraints, Claude makes five repeatable mistakes.
The first is missing pageExtensions. In Next.js App Router, .mdx files do not become routes automatically. You must add 'mdx' to the pageExtensions array in next.config.ts. Claude generates the createMDX wrapper correctly but forgets the pageExtensions line roughly half the time, producing a config that installs the plugin without actually routing any .mdx file.
The second is bare component imports inside .mdx files. MDX 3 supports ESM imports at the top of a file, but only if the bundler is configured to handle them. When Claude writes import { Chart } from '../components/Chart' inside an .mdx file without checking whether the loader is set up for it, the build silently fails or the component renders as raw text.
The third is manual heading wrappers. The correct way to style all h2 elements in an MDX document is to pass a components map: { h2: ({ children }) => <h2 className="...">{children}</h2> }. Claude instead wraps every heading directly in the MDX file, producing duplicated JSX across every document and breaking the separation between content and presentation.
The fourth is async data in MDX without RSC. In the Next.js App Router, .mdx files processed by @next/mdx are server components by default. You can run await db.query(...) directly in the MDX file. Claude does not know this and either avoids all async calls or suggests a useEffect inside the MDX, which requires marking the file 'use client' and loses server-rendering entirely.
The fifth is untyped frontmatter. MDX frontmatter is plain YAML or JSON depending on the parser. Without a TypeScript interface declared in CLAUDE.md, Claude generates frontmatter keys inconsistently across files, which breaks any system that reads frontmatter programmatically.
This guide covers the CLAUDE.md configuration that anchors Claude Code to MDX 3's actual model across Next.js, Astro, and Vite. For the Next.js App Router baseline, Claude Code with Next.js covers the configuration layer that MDX builds on top of.
The MDX CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every session. For an MDX-powered site it needs to declare: MDX version and the bundler in use, frontmatter schema with TypeScript types, the component mapping strategy, import rules for components inside .mdx files, the RSC boundary policy, syntax highlighting configuration, TOC generation approach, and the hard rules that block Claude's most common mistakes.
# MDX rules
## Stack
- MDX 3.x, TypeScript 5.x strict
- Next.js 15.x App Router with @next/mdx
- Node 20.x
## File conventions
- .mdx files in app/ are server components by default (do NOT add 'use client')
- .mdx files in content/ are processed at build time via next/mdx or a custom loader
- Component files that use hooks MUST live in src/components/ with 'use client' at top
- MDX files NEVER contain 'use client'. Use a thin client wrapper component instead
## Frontmatter schema (TypeScript)
interface PostFrontmatter {
title: string; // 50-65 characters
description: string; // 140-160 characters
date: string; // ISO 8601: "2026-05-15"
dateModified: string; // ISO 8601: "2026-05-15"
author: string;
tags: string[];
draft?: boolean; // omit on published posts
}
## Frontmatter parsing
- Use gray-matter for file-system reads in getStaticProps / generateStaticParams
- gray-matter returns { data, content }. Type data as PostFrontmatter
- For @next/mdx with pageExtensions, use remark-frontmatter +
remark-mdx-frontmatter to expose frontmatter as an exported const
- NEVER access frontmatter keys without a TypeScript interface (no implicit any)
## Components map (Next.js)
- Root file: src/mdx-components.tsx
- Export: useMDXComponents(components: MDXComponents): MDXComponents
- Map ALL prose elements you need to style: h1, h2, h3, p, a, pre, code,
ul, ol, li, blockquote, img, hr
- NEVER style headings by wrapping them manually inside an .mdx file
- Component map is the ONLY place heading/paragraph styles are defined
## Imports inside .mdx files
- Components used in .mdx MUST be available via the components prop OR
imported at the top of the .mdx file IF the bundler is configured for ESM
- For @next/mdx: ESM imports inside .mdx are supported, use them
- For Astro: components passed via the components prop or imported at top of .mdx
- For Vite + @mdx-js/rollup: ESM imports inside .mdx are supported
- NEVER use require() inside an .mdx file
- NEVER use dynamic import() inside an .mdx file for component loading
## Syntax highlighting
- Use rehype-pretty-code with Shiki (server-side, zero client JS)
- Theme: 'github-dark' for dark mode, 'github-light' for light mode
- Enable line numbers via rehype-pretty-code options
- NEVER use highlight.js or Prism (they add client-side JS and CSS)
- Code blocks in .mdx files use triple backtick + language identifier
## TOC generation
- rehype-slug: adds id attributes to all headings
- rehype-autolink-headings: wraps headings in anchor links
- Extract TOC in a separate utility using remark-parse + remark-mdx
- TOC utility returns { id, text, level }[] for client-side rendering
- NEVER extract TOC by parsing rendered HTML. Parse the AST instead
## RSC rules
- .mdx files in app/ are RSCs. You CAN await inside them (DB, fetch, fs)
- Interactive elements (useState, useEffect, event handlers) go in a separate
'use client' component that is imported into the .mdx file
- Pattern: <InteractiveWidget /> lives in src/components/InteractiveWidget.tsx
marked 'use client'; the .mdx file imports it without any 'use client' itself
## Hard rules
- NEVER forget pageExtensions: ['ts', 'tsx', 'mdx'] in next.config.ts
- NEVER use a hardcoded timer. Wait on observable state instead
- NEVER add 'use client' to an .mdx file, use a wrapper component
- NEVER style headings inline in .mdx, use the components map
- NEVER generate frontmatter without the PostFrontmatter TypeScript interface
- NEVER use Prism or highlight.js for syntax highlighting
- NEVER nest 'use client' components directly in the MDX file's JSX scope
without importing them, they must be imported explicitly
Three rules here eliminate the most common Claude mistakes.
The pageExtensions rule is the highest-impact single line in this template. Without it, next.config.ts gets the MDX plugin installed but .mdx files never become routes. The rule makes Claude add the array in the same edit as the plugin registration, so the config is complete in one pass.
The components-map-only rule for heading styles prevents the accumulation of inline JSX in content files. A documentation site with 50 .mdx files where every h2 is wrapped manually becomes unmaintainable the moment the design system changes a font size. The components map means one edit to mdx-components.tsx propagates to all files.
The RSC boundary rule is the subtlest but most architecturally important. MDX files in the App Router are server components. Marking one 'use client' to accommodate a single interactive widget removes server rendering from the entire document, including all the static prose. The correct pattern is a thin wrapper: the widget lives in a separate 'use client' file, the MDX file imports it, and the document stays server-rendered. Claude will add 'use client' to the MDX file itself when it encounters a hook inside an imported component, unless this rule is explicit.
Install and bundler config
The install path differs by bundler. Each produces the same MDX 3 runtime but requires different configuration.
Next.js App Router
npm install @next/mdx @mdx-js/loader @mdx-js/react
npm install -D remark-frontmatter remark-mdx-frontmatter rehype-pretty-code rehype-slug rehype-autolink-headings
next.config.ts:
import createMDX from '@next/mdx';
import remarkFrontmatter from 'remark-frontmatter';
import remarkMdxFrontmatter from 'remark-mdx-frontmatter';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import type { NextConfig } from 'next';
const withMDX = createMDX({
options: {
remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter],
rehypePlugins: [
[rehypePrettyCode, { theme: 'github-dark', keepBackground: false }],
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
],
},
});
const nextConfig: NextConfig = {
pageExtensions: ['ts', 'tsx', 'mdx'],
};
export default withMDX(nextConfig);
src/mdx-components.tsx at the project root:
import type { MDXComponents } from 'mdx/types';
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
h1: ({ children }) => (
<h1 className="text-4xl font-bold tracking-tight mt-8 mb-4">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold mt-10 mb-3 border-b pb-2">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold mt-8 mb-2">{children}</h3>
),
p: ({ children }) => (
<p className="my-4 leading-7">{children}</p>
),
a: ({ href, children }) => (
<a href={href} className="text-blue-600 underline underline-offset-2 hover:text-blue-800">
{children}
</a>
),
pre: ({ children }) => (
<pre className="my-6 overflow-x-auto rounded-lg border bg-zinc-950 p-4 text-sm">{children}</pre>
),
...components,
};
}
Astro
npx astro add mdx
That single command modifies astro.config.mjs and installs @astrojs/mdx. Astro's MDX integration has zero-config route discovery: any .mdx file in src/pages/ becomes a route. Add rehype plugins in astro.config.mjs:
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
export default defineConfig({
integrations: [
mdx({
rehypePlugins: [
[rehypePrettyCode, { theme: 'github-dark', keepBackground: false }],
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
],
}),
],
});
In Astro, pass custom components via the components prop when rendering an MDX layout or use a global components export in the MDX file itself.
Vite (non-framework)
npm install @mdx-js/rollup @mdx-js/react
npm install -D remark-frontmatter remark-mdx-frontmatter rehype-pretty-code rehype-slug rehype-autolink-headings
vite.config.ts:
import { defineConfig } from 'vite';
import mdx from '@mdx-js/rollup';
import remarkFrontmatter from 'remark-frontmatter';
import remarkMdxFrontmatter from 'remark-mdx-frontmatter';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
export default defineConfig({
plugins: [
{
enforce: 'pre',
...mdx({
remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter],
rehypePlugins: [
[rehypePrettyCode, { theme: 'github-dark' }],
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
],
}),
},
],
});
The enforce: 'pre' wrapper is required. Without it, Vite's own JSX transform runs before the MDX plugin, which produces a parse error on the MDX syntax. Claude generates the Vite config without enforce: 'pre' by default because the Vite docs show it only in a brief note. The CLAUDE.md template must include the wrapper explicitly.
For Astro-specific conventions, Claude Code with Astro covers the island architecture patterns that complement MDX content routes.
Frontmatter parsing with gray-matter and remark
Frontmatter sits between --- delimiters at the top of an .mdx file. Two parsing strategies cover the common use cases.
Strategy 1: gray-matter for file-system reads
When you read .mdx files at build time via fs.readFileSync or glob, gray-matter separates the YAML header from the body:
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
interface PostFrontmatter {
title: string;
description: string;
date: string;
dateModified: string;
author: string;
tags: string[];
draft?: boolean;
}
export function getPost(slug: string) {
const filePath = path.join(process.cwd(), 'content/blog', `${slug}.mdx`);
const raw = fs.readFileSync(filePath, 'utf-8');
const { data, content } = matter(raw);
return {
frontmatter: data as PostFrontmatter,
content,
slug,
};
}
Claude will cast data as any unless the interface is shown. Providing the PostFrontmatter interface in CLAUDE.md makes Claude apply the cast correctly and flag any field mismatch at compile time.
Strategy 2: remark-mdx-frontmatter for compiled MDX
When using @next/mdx with the remarkMdxFrontmatter plugin, the frontmatter is exported as a named const from the compiled MDX module:
---
title: "Getting started with MDX"
description: "Learn MDX in 10 minutes"
date: "2026-05-17"
dateModified: "2026-05-17"
author: "Claudify"
tags: ["MDX", "Documentation"]
---
This is the post body.
After compilation, the module exports export const frontmatter = { title: '...', ... }. You import it alongside the default component:
import Post, { frontmatter } from '@/content/blog/getting-started.mdx';
TypeScript needs a declaration file for .mdx modules. Add src/mdx.d.ts:
declare module '*.mdx' {
import type { MDXComponents } from 'mdx/types';
export const frontmatter: import('./types').PostFrontmatter;
export default function MDXContent(props: { components?: MDXComponents }): JSX.Element;
}
Claude omits this declaration file consistently when generating Next.js + MDX setups. Without it, frontmatter imports are typed as any, which defeats the frontmatter schema enforcement.
Custom components via the components prop
The components prop is how you inject React components into MDX content without modifying the MDX file. The pattern works identically across all three bundlers.
Element replacement replaces native HTML elements globally:
// src/app/blog/[slug]/page.tsx
import { getMDXComponent } from 'mdx-bundler/client';
import { useMDXComponents } from '@/mdx-components';
export default async function BlogPost({ params }: { params: { slug: string } }) {
const { code, frontmatter } = await getPost(params.slug);
const Content = getMDXComponent(code);
const components = useMDXComponents({});
return (
<article>
<h1>{frontmatter.title}</h1>
<Content components={components} />
</article>
);
}
Named component injection makes custom components available inside .mdx files without an import statement in the file:
// Pass custom components alongside element replacements
const components = useMDXComponents({
Callout: ({ type, children }: { type: 'info' | 'warning'; children: React.ReactNode }) => (
<div className={`callout callout-${type}`}>{children}</div>
),
CodeSandbox: ({ id }: { id: string }) => (
<iframe
src={`https://codesandbox.io/embed/${id}`}
className="w-full h-96 rounded-lg border"
title="CodeSandbox embed"
/>
),
});
Inside the .mdx file, <Callout type="info"> and <CodeSandbox id="abc123" /> now work without any import. The component is resolved at render time from the components prop.
Claude's default is to add an import statement at the top of the .mdx file for every custom component. That works when the bundler supports ESM imports in MDX, but breaks in environments where .mdx files are content-only and the loader does not process their imports. Declaring the components-prop pattern in CLAUDE.md gives Claude a single consistent strategy.
Syntax highlighting with rehype-pretty-code and Shiki
Syntax highlighting in MDX should produce zero client-side JavaScript. Shiki runs at build time, converts code blocks to styled HTML spans, and ships no runtime to the browser. rehype-pretty-code is the integration layer that connects Shiki to the MDX rehype pipeline.
The next.config.ts entry shown in the bundler section is sufficient for most setups. For fine-grained control, pass an options object:
[rehypePrettyCode, {
theme: {
dark: 'github-dark',
light: 'github-light',
},
keepBackground: false, // remove Shiki's inline background, use your CSS
defaultLang: 'plaintext', // fallback for unlabelled blocks
onVisitLine(node) {
// Prevent empty lines from collapsing
if (node.children.length === 0) {
node.children = [{ type: 'text', value: ' ' }];
}
},
onVisitHighlightedLine(node) {
node.properties.className.push('line--highlighted');
},
}]
In the .mdx file, annotate code blocks with the language and optional line highlights:
```ts {3-5} showLineNumbers
import { createMDX } from '@next/mdx';
const withMDX = createMDX({
options: {},
});
```
{3-5} highlights lines 3 through 5. showLineNumbers adds a counter column. Both are rehype-pretty-code features that require no client JS.
Claude will suggest highlight.js or Prism when rehype-pretty-code is not in CLAUDE.md. Both require client-side scripts for dynamic highlighting of code blocks not present at server render time. The explicit rule in the CLAUDE.md template prevents that substitution.
TOC generation
A table of contents requires two things: heading IDs (for anchor links) and a data structure of headings (for the rendered list). rehype-slug handles the first. A remark AST traversal handles the second.
rehype-slug is already in the plugin chain from the bundler config. It adds id attributes derived from heading text: ## Custom components becomes <h2 id="custom-components">. rehype-autolink-headings wraps each heading in an <a> pointing to that ID.
For the TOC data structure, parse the raw MDX source with remark and extract heading nodes:
import { remark } from 'remark';
import remarkMdx from 'remark-mdx';
import { visit } from 'unist-util-visit';
import GithubSlugger from 'github-slugger';
interface TocEntry {
id: string;
text: string;
level: 1 | 2 | 3;
}
export async function extractToc(source: string): Promise<TocEntry[]> {
const slugger = new GithubSlugger();
const entries: TocEntry[] = [];
const tree = remark().use(remarkMdx).parse(source);
visit(tree, 'heading', (node: any) => {
if (node.depth > 3) return;
const text = node.children
.filter((c: any) => c.type === 'text' || c.type === 'inlineCode')
.map((c: any) => c.value)
.join('');
entries.push({
id: slugger.slug(text),
text,
level: node.depth as 1 | 2 | 3,
});
});
return entries;
}
Use github-slugger rather than a custom slug function. rehype-slug uses it internally, so the IDs match. A mismatch between the TOC link and the heading ID produces broken anchor navigation.
The TOC component itself is a client component (it highlights the active section on scroll) while the heading IDs it links to are server-rendered. This is the correct RSC split: the heading structure is static, the scroll tracking is interactive.
// src/components/TableOfContents.tsx
'use client';
import { useEffect, useState } from 'react';
import type { TocEntry } from '@/lib/extract-toc';
export function TableOfContents({ entries }: { entries: TocEntry[] }) {
const [activeId, setActiveId] = useState<string>('');
useEffect(() => {
const observer = new IntersectionObserver(
(items) => {
const visible = items.find((i) => i.isIntersecting);
if (visible) setActiveId(visible.target.id);
},
{ rootMargin: '0% 0% -80% 0%' }
);
entries.forEach(({ id }) => {
const el = document.getElementById(id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [entries]);
return (
<nav aria-label="Table of contents">
<ul className="space-y-1 text-sm">
{entries.map(({ id, text, level }) => (
<li key={id} style={{ paddingLeft: `${(level - 1) * 12}px` }}>
<a
href={`#${id}`}
className={activeId === id ? 'text-blue-600 font-medium' : 'text-zinc-500 hover:text-zinc-800'}
>
{text}
</a>
</li>
))}
</ul>
</nav>
);
}
The page server component fetches the TOC data and passes it as a prop. No client-side heading parsing, no HTML traversal, no querySelectorAll at mount time.
RSC vs client components in MDX
The App Router's default is server. An .mdx file in app/ is a server component unless you add 'use client'. This is good: the prose, metadata, and code blocks render on the server and ship as HTML. The problem is interactive elements.
The rule: interactive logic lives in a separate file, marked 'use client'. The MDX file imports it. The MDX file itself stays server.
A worked example: you have an MDX tutorial with a live code playground embedded partway through.
// src/components/LiveEditor.tsx
'use client';
import { useState } from 'react';
import { Editor } from '@monaco-editor/react';
interface LiveEditorProps {
initialCode: string;
language: string;
}
export function LiveEditor({ initialCode, language }: LiveEditorProps) {
const [code, setCode] = useState(initialCode);
return (
<div className="my-6 rounded-lg border overflow-hidden">
<Editor
height="300px"
language={language}
value={code}
onChange={(val) => setCode(val ?? '')}
theme="vs-dark"
/>
</div>
);
}
In the MDX file:
import { LiveEditor } from '@/components/LiveEditor';
## Try it yourself
Edit the code below to see the output change.
<LiveEditor
initialCode={`const x = 1 + 1;\nconsole.log(x);`}
language="typescript"
/>
The component above uses Monaco Editor, which is a client-side library.
The surrounding prose is server-rendered static HTML.
The MDX file has no 'use client'. The LiveEditor import pulls in a client boundary automatically because LiveEditor.tsx declares it. Everything outside <LiveEditor /> ships as static HTML. Everything inside <LiveEditor /> hydrates on the client.
Claude's mistake without this rule is to add 'use client' to the MDX file itself when it sees the Monaco import. That converts the entire document, including headings, paragraphs, and code blocks, to client-rendered content. Page weight goes up, LCP goes down, and the document is no longer statically indexable. The RSC boundary rule in CLAUDE.md prevents this.
For Next.js App Router patterns beyond MDX, Claude Code with Next.js covers server actions, route handlers, and the broader server component model.
Common Claude mistakes and how the template prevents them
Even with a CLAUDE.md in place, four patterns warrant review after Claude generates MDX code.
Missing mdx.d.ts for TypeScript projects. Claude adds the @next/mdx config and the useMDXComponents export, but skips the type declaration file for *.mdx modules. Without it, importing frontmatter from an MDX module produces any. The fix is to add src/mdx.d.ts as shown in the frontmatter section. The CLAUDE.md template can include the declaration inline as a hard requirement, but Claude sometimes treats declaration files as optional. Check after every MDX TypeScript setup.
components prop not threaded to nested layouts. In Next.js, useMDXComponents returns the components map, but the map only reaches the rendered content if it is passed as components={components} to the MDX content component. Claude sometimes calls useMDXComponents({}) at the page level and forgets to pass the result. The rendered MDX then uses native HTML elements, and all your custom styles vanish. Verify the <Content components={components} /> call in the page component.
Remark plugins in the wrong order. Frontmatter parsing via remark-frontmatter + remark-mdx-frontmatter must run before any plugin that transforms the AST. rehype-pretty-code runs in the rehype phase (after remark), so ordering is less critical there. But if you add a custom remark plugin that rewrites headings, it must come after remark-mdx-frontmatter has consumed the YAML block. Claude does not infer plugin ordering from the names alone. The CLAUDE.md template shows the correct array order explicitly.
Layout export conflicts with App Router. MDX supports a default export convention: export default function Layout({ children }) { ... } inside the MDX file sets a custom layout. In the App Router this conflicts with Next.js's own layout system. Claude sometimes generates the MDX default export pattern for App Router projects where it does not apply. Use the App Router's layout.tsx files for layout, not MDX default exports.
For the Astro-specific version of these issues, Claude Code with Astro covers Astro's content collections API, which provides a structured alternative to manual frontmatter parsing.
Building a content layer that stays correct
The MDX CLAUDE.md template in this guide produces a setup where .mdx files are routed correctly because pageExtensions is never missing, heading styles are maintainable because only the components map defines them, frontmatter is typed because every file generates against PostFrontmatter, syntax highlighting ships zero client JS because rehype-pretty-code runs server-side, and interactive elements are isolated because the RSC boundary rule keeps client code in separate files.
The underlying principle is the same across every framework integration with Claude Code. MDX without a CLAUDE.md produces setups where the first client component added to a post converts the entire document to client rendering, where a heading style change requires editing 30 files, and where frontmatter keys silently drift across posts. With the configuration above there is one failure mode: a deliberate architectural change. Every generated MDX file follows the same contract, regardless of which developer on the team invoked Claude.
For the mechanics of how CLAUDE.md is read at session start and how to version it alongside your content layer, see CLAUDE.md explained. Claudify includes an MDX-specific CLAUDE.md template pre-configured for all three bundlers, the component map pattern, Shiki syntax highlighting, TOC generation, and the RSC boundary rules shown in this guide.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify