MDX
Pre-configured MDX support with frontmatter parsing, custom components, and dynamic routing.
Next.js installs @mdx-js/loader, @mdx-js/react, @next/mdx and next-mdx-remote. TanStack Start installs @mdx-js/rollup, remark-frontmatter and remark-mdx-frontmatter.
TanStack Start
Documents in contents/ are compiled at build time by @mdx-js/rollup and loaded lazily with import.meta.glob, one chunk per document. YAML frontmatter is exported as frontmatter. The example route /mdx/{-$slug} renders /mdx as home.mdx and /mdx/cool as cool.mdx.
Files added:
src/
├── lib/
│ └── mdx.ts # Lazy document loader
├── mdx-components.tsx # Custom MDX component mappings
├── styles/
│ └── mdx.css # MDX-specific styling
└── routes/mdx/
└── {-$slug}.tsx # MDX route example
contents/
├── home.mdx # Example content
└── cool.mdx # Example contentModified files:
vite.config.ts- Adds the@mdx-js/rollupplugin withremark-frontmatterandremark-mdx-frontmattersrc/styles.css- Importsstyles/mdx.csssrc/routes/index.tsx- Links to the example page
Next.js
Documents in contents/ are read with fs and rendered by next-mdx-remote/rsc. The example route /mdx/[[...slug]] prerenders one page per document at build time, /mdx for home.mdx and /mdx/cool for cool.mdx, and returns 404 for any other slug. It also builds with cloudflare-static, where each document is exported as an HTML page.
Files added:
src/
├── lib/
│ └── mdx.ts # parseFrontmatter() utility
├── mdx-components.tsx # Custom MDX component mappings
├── styles/
│ └── mdx.css # MDX-specific styling
└── app/mdx/[[...slug]]/
└── page.tsx # MDX route example
contents/
├── home.mdx # Example content
└── cool.mdx # Example contentModified files:
next.config.ts- Adds MDX support withcreateMDXand.mdx/.mdextensions
mdx.ts
Frontmatter parsing utility:
export type MdxFrontmatter = {
title: string;
summary: string;
publishedAt: string;
imageUrl?: string;
};
export type ParsedFrontmatterReturn = {
metadata: Partial<MdxFrontmatter>;
content: string;
};
export function parseFrontmatter(fileContent: string): ParsedFrontmatterReturn {
const frontmatterRegex = /---\s*([\s\S]*?)\s*---/;
const match = frontmatterRegex.exec(fileContent);
const frontMatterBlock = match![1];
const content = fileContent.replace(frontmatterRegex, '').trim();
const frontMatterLines = frontMatterBlock.trim().split('\n');
const metadata: Partial<MdxFrontmatter> = {};
frontMatterLines.forEach((line) => {
const [key, ...valueArr] = line.split(': ');
let value = valueArr.join(': ').trim();
value = value.replace(/^['"](.*)['"]$/, '$1');
metadata[key.trim() as keyof MdxFrontmatter] = value;
});
return { metadata, content };
}page.tsx
MDX route prerendered for every document, with metadata generation:
import fs from 'node:fs';
import path from 'node:path';
import { notFound } from 'next/navigation';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { parseFrontmatter } from '@/lib/mdx';
import { mdxComponents } from '@/mdx-components';
const CONTENTS_DIR = path.join(process.cwd(), 'contents');
const HOME_DOCUMENT = 'home';
interface PageProps {
params: Promise<{ slug?: string[] }>;
}
export function generateStaticParams(): { slug: string[] }[] {
return fs
.readdirSync(CONTENTS_DIR)
.filter((file) => file.endsWith('.mdx'))
.map((file) => path.basename(file, '.mdx'))
.map((name) => ({ slug: name === HOME_DOCUMENT ? [] : [name] }));
}
export const generateMetadata = async ({ params }: PageProps) => {
const { slug } = await params;
const { metadata } = parseFrontmatter(readDocument(slug));
return { title: metadata.title, description: metadata.summary };
};
export default async function Page({ params }: PageProps) {
const { slug } = await params;
const { metadata, content: mdxContent } = parseFrontmatter(readDocument(slug));
return (
<main className='max-w-162.5 mx-auto px-4 py-4 md:py-8'>
<MDXRemote source={mdxContent} components={mdxComponents} />
<pre>
<code>{JSON.stringify({ metadata }, null, 2)}</code>
</pre>
</main>
);
}
function readDocument(slug: string[] = []): string {
const isListed = generateStaticParams().some(
(params) => params.slug.length === slug.length && params.slug.every((segment, index) => segment === slug[index]),
);
if (!isListed) notFound();
const name = slug[0] ?? HOME_DOCUMENT;
return fs.readFileSync(path.join(CONTENTS_DIR, `${name}.mdx`), 'utf-8');
}
