How to make a Table of Contents using Tocbot with Contentlayer
Next.js

How to make a Table of Contents using Tocbot with Contentlayer

Table of contents helps the visitors quickly get the summary of the article, and navigate to the sections that they are interested in.

In this article, I'll show you how to create a table of contents using Tocbot with Contentlayer and Next.js.

This tutorial is designed for people who use Next.js, Contentlayer, and markdown or MDX in their website.

What we build

Before we start, I'll show you what kind of a table of contents we are gonna create in this article.

  • You can place it anywhere (in the blog contents, or a sidebar, etc)
  • Smooth scrolling to the anchor point when clicked
  • Offset value is available if you have a sticky header
  • Choose the levels of heading tags you want to show
  • Set an active class to highlight the current section on scroll

Basically, we are gonna create the same table of contents you see on the sidebar in this page. (If you don't see it, increase your browser width to 1280px or up)

If this is what you wanna build, then let's get started!

Step 1. Install packages

First, we need to install the following packages.

console
npm install rehype-slug tocbot

These are all we need. I'll explain what these packages will do later on.

Step 2. Add ids to heading tags using rehype-slug

In order to set anchor links to heading tags, each one must have an id attribute with a unique value.

To achieve this, we are gonna use rehype-slug.

rehype-slug does the following work automatically.

  • Add ids to h1 - h6 tags using the inner text as a value
  • Make sure each id value will be unique

How rehype-slug works

Let me show you how this plugin works. Let's say your mdx file has a content below.

MDX
## This is heading 2

### This is heading 3

#### Sample heading

##### Sample heading

When this is rendered in the browser, it looks like this.

HTML
<h2 id="this-is-heading-2">This is heading 2</h2>

<h3 id="this-is-heading-3">This is heading 3</h3>

<h4 id="sample-heading">Sample heading</h4>

<h5 id="sample-heading-1">Sample heading</h5>

rehype looks for heading tags, and adds the id attributes with the value of the text content inside.

Also, it generates unique id values even when the heading tags have the same text strings. You can see it by looking at two headings with Sample heading strings inside. When rehype-slug detects the same strings, it adds an additional value to make them unique.

Set up rehype-slug in contentlayer.config.js

To use the rehype plugin, you need to import it in contentlayer.config.js.

contentlayer.config.js
JavaScript
import rehypeSlug from "rehype-slug";

export default makeSource({
  contentDirPath: "posts", // your own setting
  documentTypes: [Post], // your own setting
  mdx: {
    rehypePlugins: [rehypeSlug],
  },
});

This is all you need. You simply include rephype-slug in the rehypePlugins array. If you navigate to your blog page, you will see the headings with id attributes.

Step 3. Create a toc component

Now, let's start building the table of contents using Tocbot. First we create a new file toc.jsx, and copy & paste the code below.

/components/toc.jsx
JSX
import { useEffect } from "react";
import tocbot from "tocbot";

export default function Toc() {
  useEffect(() => {
    tocbot.init({
      tocSelector: ".js-toc", // Select the wrapper of toc
      contentSelector: ".js-toc-content", // Select the warpper of contents
      headingSelector: "h2, h3", // Choose the heading tags
      /* Optional 1.
      Enable these if you have a sticky header and adjust the offset value
      */
      // headingsOffset: 100,
      // scrollSmoothOffset: -100,

      /* Optional 2. 
      Enable this if 'active' class on scroll won't work properly
      */
      // hasInnerContainers: true,
    });

    return () => tocbot.destroy();
  }, []);

  return (
    <div>
      <span>Table of Contents</span>
      <div className="js-toc"></div>
    </div>
  );
}

This is the basic setup with minimum options.

I'm gonna break these down and explain individually in the next section.

3-1. Initialize tocbot

We initialize tocbot using React useEffect.

After the component is rendered, it creates the table of contents. Once it's done, call destroy method to remove event listeners.

/components/toc.jsx
JSX
useEffect(() => {
  tocbot.init({
    tocSelector: ".js-toc", // Select the wrapper of toc
    contentSelector: ".js-toc-content", // Select the warpper of contents
    headingSelector: "h2, h3", // Choose the heading tags
    /* Optional 1.
    Enable these if you have a sticky header and adjust the offset value
    */
    // headingsOffset: 100,
    // scrollSmoothOffset: -100,

    /* Optional 2. 
    Enable this if 'active' class on scroll won't work properly
    */
    // hasInnerContainers: true,
  });

  return () => tocbot.destroy();
}, []);

You can pass some options when initializing the tocbot. These 3 options are essential in order to create a ToC.

  • tocSelector - Where to render the table of contents.
  • contentSelector - Where to grab the headings to build the table of contents.

  • headingSelector - Which headings to grab inside of the contentSelector element.

About the optional settings and other options, check out the official Tocbot page.

3-2. Create the Table of Contents template

We need to create a template to show the table of contents.

/components/toc.jsx
JSX
import { useEffect } from "react";
import tocbot from "tocbot";

export default function Toc() {
  useEffect(() => {
    tocbot.init({
      tocSelector: ".js-toc",
      contentSelector: ".js-toc-content",
      headingSelector: "h2, h3",
    });
    return () => tocbot.destroy();
  }, []);

  return (
    <div>
      <span>Table of Contents</span>
      <div className="js-toc"></div>
    </div>
  );
}

First, you need to have the container of the generated table of contents. In this code, the div tag with the className of js-toc will be the container because we set tocSelector value to js-toc.

The rest is totally up to you. You can add more tags or some icons if you want.

Step 4. Add a class to the wrapper of blog content in a single page

We need to tell Tocbot where to grab all headings to make the table of contents. In order to do this, we need to add a few lines to a single page (in my case /pages/posts/[slug].js).

If you have a MDX blog with Contentlayer, your [slug].js file should look something like this:

pages/posts/[slug].js
JSX
import { useMDXComponent } from "next-contentlayer/hooks";
import { allPosts } from "contentlayer/generated";

const mdxComponents = {
  // ...
};

const PostLayout = ({ post }) => {
  const MDXContent = useMDXComponent(post.body.code);

  return (
    <>
      {/* Other contents... */}
      <MDXContent components={mdxComponents} />
    </>
  );
};

export default PostLayout;

export async function getStaticProps({ params }) {
  // ...
}

export async function getStaticPaths() {
  // ...
}

<MDXContent /> is where the MDX content will be rendered. So, we add a div tag with js-toc-content class and wrap up <MDXContent /> like this:

pages/posts/[slug].js
JSX
return (
  <>
    <div className="js-toc-content">
      <MDXContent components={mdxComponents} />
    </div>
  </>
);

By doing this, Tocbot will look for heading tags inside of js-toc-content and generate the table.

Step 5. Import the toc component into your page

Finally, we are gonna import the toc in your page. There are two ways to do this, depending on where you want to show the table of contents.

1. Use the table of contents inside of .mdx files

If you wanna embed the table of contents inside of your .mdx files, first you need to add it to mdxComponents.

Go to your single page ([slug].js), and add the highlighted lines:

pages/posts/[slug].js
JSX
import { useMDXComponent } from "next-contentlayer/hooks";
import { allPosts } from "contentlayer/generated";
import { Toc } from "@/components/toc";

const mdxComponents = {
  Toc,
};

const PostLayout = ({ post }) => {
  const MDXContent = useMDXComponent(post.body.code);

  return (
    <>
      {/* Other contents... */}
      <MDXContent components={mdxComponents} />
    </>
  );
};

export default PostLayout;

export async function getStaticProps({ params }) {
  // ...
}

export async function getStaticPaths() {
  // ...
}

Now that the <Toc /> component is ready to be used in .mdx files, you can call it like this:

/posts/sample.mdx
MDX
---
title: Sample mdx file
publishedAt: 2023-02-04T20:00:00
---

This is a sample content.

<Toc />

This is a sample content...

The cool thing about this method is that you have a control over where to show and whether to show the table of contents on each page.

2. Use the table of contents outside of .mdx files

The common case for this is when you want to show the table of contents in a sidebar. In this case, you can simply import and use it in the file that you actually wanna show it just like other components.

In my blog, I show it in the sidebar which is located in [slug].js file.

/pages/posts/[slug].js
MDX
import { useMDXComponent } from "next-contentlayer/hooks";
import { allPosts } from "contentlayer/generated";
import { Toc } from "@/components/toc";

const mdxComponents = {
  // ...
};

const PostLayout = ({ post }) => {
  const MDXContent = useMDXComponent(post.body.code);

  return (
    <>
      <main>
        <MDXContent components={mdxComponents} />
      </main>
      <aside>
        <Toc />
      </aside>
    </>
  );
};

export default PostLayout;

export async function getStaticProps({ params }) {
  // ...
}

export async function getStaticPaths() {
  // ...
}

This is it! Now you have successfully implemented the table of contents in your website.

Final thoughts

I originally tried to create the table of contents on my own, but then I realized it would save a lot of time if I used a package. Luckily Tocbot has everything I wanted for my table of contents, so I'm satisfied with this for now.

If you have any questions about this, feel free to send me a message! I'll do my best to help you.