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.

Post ThumbnailPost 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 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

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.