How to set up a table of contents in MDX

4 min read

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

Blurry background of Post Thumbnail Post Thumbnail

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:

vite.config.ts
      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{:ts} to access the component exports.

app / lib / posts.ts
      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",
});
    
Tip

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:

app / lib / posts.ts
      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

A little note from me

I put a lot of effort into creating content that is informative, engaging, and useful for my readers. If you find it valuable, please consider subscribing to my newsletter.

You can now use this function in your route loader to fetch the ToC:

app / routes / blog / layout.tsx
      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:

app / components / TableOfContents.tsx
      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>
  );
}
    
Warning

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.

app / routes / blog / layout.tsx
      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>
  );
}
    
Tip

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.