How to set up a table of contents in MDX

Learn how to automatically extract a structured table of contents from MDX posts using rehype plugins, Vite, and TypeScript.


Recently I added the Table of Contents you see on this post to my website. I'm using MDX to write blog posts and wanted a ToC that updates automatically based on the post's headings.
Here's how I set it up and how you can too.
#Install the necessary package
The easiest and most reliable solution I found is rehype-extract-toc
. It parses all heading elements in your MDX and turns them into a structured table of contents object, which can then be exported from the file as a named component export.
npm install @stefanprobst/rehype-extract-toc
#Configure the MDX plugin
My MDX setup uses @mdx-js/rollup
, since I’m working with React Router 7 and Vite. Here’s how I added the plugin to Vite's config file, based on the official documentation:
import type { UserConfig } from "vite";
import mdx from "@mdx-js/rollup";
import rehypeExtractToc from "@stefanprobst/rehype-extract-toc";
import rehypeExtractTocExport from "@stefanprobst/rehype-extract-toc/mdx";
export default {
plugins: [
// other plugins...
mdx({
rehypePlugins: [
rehypeExtractToc,
// Optional: use a custom export name
// [rehypeExtractTocExport, { name: "customExportName" }],
rehypeExtractTocExport,
],
}),
],
} satisfies UserConfig;
#Access the table of contents
Because I’m using Vite, I take advantage of import.meta.glob
to access the component exports.
import type { Toc } from "@stefanprobst/rehype-extract-toc";
const posts = import.meta.glob<Toc>("../routes/blog/**/*.mdx", {
eager: true,
// This is the default export name, but you can change it in the plugin config (see above)
import: "tableOfContents",
});
Importing named exports together with eager: true
allows for
tree-shaking.
This gives you an object with file paths as keys and ToC entry arrays as values:
'../routes/blog/posts/drafts/mdx-table-of-contents/route.mdx': [
{
depth: 2,
value: 'Install the necessary package',
id: 'install-the-necessary-package'
},
{
depth: 2,
value: 'Configure the MDX plugin',
id: 'configure-the-mdx-plugin',
},
...
]
#Map the file paths to your routes
This depends on your setup. With React Router 7, I map file paths to route slugs using the route manifest:
export async function getPosts() {
const { routes } = await import("virtual:react-router/server-build");
let files = Object.entries(posts).map(([path, tableOfContents]) => {
const id = path.replace("../", "").replace(/\.mdx$/, "");
const slug = routes[id]?.path;
if (slug === undefined) throw new Error(`No route for ${id}`);
return {
slug: `/${slug}`,
tableOfContents,
};
});
}
#Load the table of contents in your layout
You can now use this function in your route loader to fetch the ToC:
import { getPosts } from "~/lib/posts";
export async function loader({ request }: Route.LoaderArgs) {
const { pathname } = new URL(request.url);
const posts = await getPosts();
const post = posts.find((post) => post.slug === pathname);
if (!post) {
throw new Response("Not Found", { status: 404 });
}
return { post };
}
export default function PostLayout({ loaderData }: Route.ComponentProps) {
const { post } = loaderData;
const { tableOfContents } = post;
// ...
}
#Create the Table of Contents component
To render the ToC, I wrote a recursive component that can handle nested entries. The plugin provides TypeScript types Toc
and TocEntry
out of the box.
Here’s a basic, unstyled version of the component I put together:
import type { Toc, TocEntry } from "@stefanprobst/rehype-extract-toc";
export default function TableOfContents({
outline,
maxDepth,
...props
}: {
outline: Toc;
maxDepth: number;
} & React.ComponentProps<"nav">) {
return (
<nav {...props}>
<p>Table of Contents</p>
<ol>
{outline.map((entry) => (
<TocItem key={entry.value} maxDepth={maxDepth} {...entry} />
))}
</ol>
</nav>
);
}
function TocItem({
depth,
value,
id,
children,
maxDepth,
}: TocEntry & { maxDepth: number }) {
if (depth > maxDepth) return null;
return (
<li>
<a href={`#${id}`}>{value}</a>
{children && children.length > 0 && (
<ol>
{children.map((entry) => (
<TocItem key={entry.value} maxDepth={maxDepth} {...entry} />
))}
</ol>
)}
</li>
);
}
To make the anchor links work, make sure your headings have id
attributes.
You can add them using a plugin like
rehype-slug
.
#Display the component
Render the ToC conditionally inside your post layout.
export default function PostLayout({ loaderData }: Route.ComponentProps) {
const { post } = loaderData;
const { tableOfContents } = post;
return (
<article>
{tableOfContents.length > 0 ? (
<TableOfContents maxDepth={3} outline={tableOfContents} />
) : null}
{/* The rest of your post content goes here */}
</article>
);
}
If you want to see the fully styled component and implementation, you can check out the commit that added this to my site.
You now have a fully automated, table of contents setup for your MDX blog posts.