DEV Community

Billy Le
Billy Le

Posted on • Originally published at billyle.dev on

Creating Custom Table of Contents for Astro Content Collections

Having a Table of Contents (ToC) for your blog is nice because it allows users to see an overview of your content and provides a quick way to navigate between sections. To do this in Astro with the Content Collections API, there's a bit of legwork, but the results are satisfying. But first, let's talk about the issues I ran into.

The problem

I tried to do this with the plugin, 'remark-toc' , which is mentioned in the Astro documentation. What I didn't like about it is that if I wanted to include a ToC, I would have to manually add it to the top of all my .md files.

remark-toc display output on the blog post

Another note is that wherever I included it in the markdown, the ToC sits statically and to style it, I would have to target the id and fight my existing blog layout.

using remark-toc in a markdown file

This is not ideal for me. I had to figure out a better way to do this.

Retrieving all headers of a markdown

In the official documentation, there are two ways you can get all the headings of your blog posts. The two ways are when you're importing a .md into a .astro file or using the Astro.glob() function. Neither of those was valid in my case because I'm using the Content Collections API.

Buried in the documentation, I found that you can get the headings from a RenderedEntry if you're using the Content Collections API.

Inside of my /src/pages/posts/[...slug].astro where I am pre-rendering my blog posts using getStaticPaths(), I have a utility function that pulls all my published blog posts into a collection. From there, I extract the headings using a Promise.all().

---
import BlogLayout from "../../layouts/BlogLayout.astro";
import { allPosts } from "@utils/getCollection";
import type { GetStaticPaths } from "astro";

export const getStaticPaths = (async () => {
  const headings = await Promise.all(
    allPosts.map((entry) => entry.render().then((data) => data.headings)),
  );

  const posts = allPosts.map((entry, index) => {
    return {
      params: { slug: entry.slug },
      props: { entry, headings: headings[index] },
    };
  });

  return posts;
}) satisfies GetStaticPaths;

const { entry, headings } = Astro.props;
const { Content } = await entry.render();
---

<BlogLayout {...entry.data} {headings}>
  <Content />
</BlogLayout>

Enter fullscreen mode Exit fullscreen mode

For reference, inside an Astro Collection, you have a list of Entries. These Entries have a render() method that compiles the .md file for rendering. It also returns a property called headings which I used here to collect all the headings in a given markdown.

Here is the shape of that:

// headings shape
const headings: {
  depth: number;
  text: string;
  slug: string;
}[];

Enter fullscreen mode Exit fullscreen mode

With that, I returned it inside the props object which can be extracted from Astro.props.

Creating the heading hierarchy

I have all the headings passed down to my BlogLayout component, and now I can use it. The first thing I need to do is make sure that there is a hierarchy of headings so that the ToC properly indents the headings.

I tried doing this on my own with a recursive function but didn't have too much success. Luckily, I came across this blog by Reza Zahedi that showed me a good foundation to start with.

With the stolen copied code, I noticed that the nesting only allowed one list of subheadings. So if a heading has a depth of 2, and two headings succeeding that is of depth of 3 and 4 respectively, then it outputs something like this:

const nestedHeadings = [
  {
    depth: 2,
    text: "My Heading",
    slug: "my-heading",
    subheadings: [
      {
        depth: 3,
        text: "My Subheading 1",
        slug: "my-subheading-1",
      },
      {
        depth: 4,
        text: "My Subheading 2",
        slug: "my-subheading-2",
      },
    ],
  },
];

Enter fullscreen mode Exit fullscreen mode

I was okay with this since I do not want the ToC to get carried away with indentations. I wanted to prevent from writing any headings greater than 3, so I added a guard to throw an error if I did include one by accident.

Inside my TOCHeading.astro component, I'm exporting an interface:

import type { MarkdownHeading } from "astro";
export interface HeadingHierarchy extends MarkdownHeading {
  subheadings: HeadingHierarchy[];
}

Enter fullscreen mode Exit fullscreen mode

Inside my BlogLayout.astro component:

import type { HeadingHierarchy } from "@ui/components/TOCHeading.astro";
import type { MarkdownHeading } from "astro";

const { headings } = Astro.props;

function createHeadingHierarchy(headings: MarkdownHeading[]) {
  const topLevelHeadings: HeadingHierarchy[] = [];

  headings.forEach((heading) => {
    if (heading.depth > 3) {
      throw Error(
        `Depths greater than 3 not allowed:\n${JSON.stringify(heading, null, 2)}`,
      );
    }
    const h = {
      ...heading,
      subheadings: [],
    };

    if (h.depth === 2) {
      topLevelHeadings.push(h);
    } else {
      let parent = topLevelHeadings[topLevelHeadings.length - 1];
      if (parent) {
        parent.subheadings.push(h);
      }
    }
  });

  return topLevelHeadings;
}

const toc: HeadingHierarchy[] = createHeadingHierarchy(headings ?? []);
const hasToC = toc.length > 1;

Enter fullscreen mode Exit fullscreen mode

I'm using a variable called hasToC since in some cases I have a short blog post with only one heading, and it doesn't make sense to show the ToC. I use this variable to conditionally render the ToC and the appropriate layout.

Rendering the ToC

Rendering is fairly straightforward in Astro. I have a TOCHeading.astro component that I found in the other blog post and made minor adjustments like giving it types and such.

If you're going to use a sticky ToC, be sure that the parent component has a position: relative and that there is no overflow property on it. If your parent is a flex or grid parent, you need to wrap your position: sticky ToC with a container so that it will properly work.

<section class={`${hasToC ? "max-w-7xl mx-auto lg:grid lg:grid-cols-4" : ""}`}>
  {hasToC && (
    <div class="relative mx-auto px-4 prose dark:prose-invert xl:pt-10 2xl:px-0">
      <nav class="xl:sticky xl:top-20">
        <h2 class="text-emerald-400">Table of Contents</h2>
        <ul>
          {toc.map((heading) => (
            <TOCHeading heading={heading} />
          ))}
        </ul>
      </nav>
    </div>
  )}

  <article
    class={`py-10 sm:py-20 px-4 mx-auto prose prose-h1:font-vidaloka dark:prose-invert
            prose-code:before:hidden prose-code:after:hidden
            sm:prose-lg lg:prose-xl
            ${hasToC ? "lg:col-span-3" : ""}
        `}
  >
    <h1>{title}</h1>
    <slot />
  </article>
</section>

Enter fullscreen mode Exit fullscreen mode

The results

As you can see, my Table of Contents appears on the left-hand side. Now you can easily move between sections as you read!

For now, I'm only supporting the sticky ToC for desktops as I haven't found a good UI for tablets and mobile devices yet.

I guess all that's left to do is highlight the ToC heading that is currently being viewed, but I'll do that some other time.

I hope that was a bit helpful if you're trying to add a ToC for your Astro website if you're using the Content Collections API.

Well, thanks for reading and I hope you have a good one.

Sentry image

Hands-on debugging session: instrument, monitor, and fix

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

RSVP here →

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay