Abdurezak
Published on

How to Make a Table of Contents from Sanity Block Content

Abdurezak Farah

Abdurezak Farah

@abdurezakfarah

Introduction

While I was building my portfolio and using Sanity as a headless CMS, I ran into an issue: I couldn’t figure out how to create a table of contents (TOC) for my blog posts. Like, you can see the TOC for this very blog now, but back then, I was pretty stuck. I searched online, and to my surprise, there weren’t any clear, updated guides on how to do it. The few I found were either confusing or just outdated.

After a lot of trial and error (and research), I finally figured out a simple and effective way to create a dynamic TOC from Sanity block content. So, I decided to share this guide to help anyone else in the same boat—it’s much easier now!

What We'll Need: Tools and Setup

Before we dive into building our table of contents, let’s quickly go over what we’ll need to get started. First off, we need to have a Sanity Studio project set up with some content blocks ready to go. If you're new to Sanity, no worries—it's pretty beginner-friendly once you get the hang of it, and we’ll figure it out together.

You’ll also want to be familiar with JavaScript and React. For this tutorial, we’re using Next.js as our framework, along with TypeScript to keep our code nice and typed, and Tailwind CSS to handle the styling. These tools will make the whole process smoother, especially when we dynamically generate the TOC from the Sanity block content.

Speaking of Sanity’s block content—if you're new to it, here’s a quick overview. It's a flexible content structure that lets you create rich, modular text fields. This is exactly what we need to pull out elements like headings for our TOC. We’ll use GROQ, Sanity’s query language, to do this efficiently.

Alright, with your setup ready, let’s jump into the fun part—actually building the table of contents!

Step 1: Grabbing Headings from Sanity Block Content

Alright, the first thing we need to do is grab the headings from the Sanity block content so we can use them in our TOC. We’ll query this content and pull out exactly what we need. By doing this, our TOC will dynamically reflect whatever content we have in Sanity. Ready? Let’s get into it!

Now, let’s assume we’ve already set up a simple schema for a blog post. It might look something like this:

schemas/post.ts
import {defineType} from "sanity"

export const post = defineType({
  name: "post", 
  title: "Post", 
  fields: [
    // other fields...
    {
      name: 'content',
      title: "Content",
      type: 'array', 
      of: [{
        type: "block"
      }]
    }
  ]
})

Here, our content field is an array of blocks. Each block can be styled as a heading (like h1, h2, h3, etc.), a paragraph, or even other types of content.

So this is a glimpse of what our content type might look like. We’ll be working with this setup to pull out the headings dynamically for our table of contents.

Array<{
  children?: Array<{ marks?: Array<string>; text?: string; _type: 'span'; _key: string; }>;
  style?: 'normal' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'blockquote';
  listItem?: 'bullet' | 'number';
  markDefs?: Array<{ href?: string; _type: 'link'; _key: string; }>;
  level?: number;
  _type: 'block';
  _key: string;
}>

We’re particularly interested in blocks where the style is set to one of the heading levels (from h1 to h6). These are the blocks we’ll use to build our TOC.

Now, to actually fetch these heading blocks, we can use the following GROQ query:

sanity/lib/queries.ts
import { groq } from 'next-sanity';

export const postPageQuery = groq`
*[_type == "post" && slug.current == $slug][0] {
  ...,
  "headings": content[style in ["h1", "h2", "h3", "h4", "h5", "h6"]]
}
`

The line: content[style in ["h1", "h2", "h3", "h4", "h5", "h6"]] filters the content array to include only blocks where the style is one of the heading levels. It returns an array of heading blocks which style is just from h1 to h6 and will be used to generate the table of contents.

Step 2: Structuring the Table of Contents in React

Post

Now that we’ve queried the heading blocks from Sanity, it’s time to turn those headings into a structured table of contents in React. The first thing we need to do is organize our array of headings into a hierarchical format. This allows us to display the headings in a nested format that mirrors the content’s hierarchy (like h2 under h1, h3 under h2, etc.).

Let’s start by defining the types we need for our Table of Contents and the hierarchy of headings:

sanity-toc.tsx
import { PostPageQueryResult } from '@/sanity/sanity.types';

// Define the type for the Headings
type Headings = NonNullable<PostPageQueryResult>['headings'];

// Define the type for each node in the tree structure
type TreeNode = {
  text: string;
  slug: string;
  children?: TreeNode[];
};

First, we define the type for our Headings using PostPageQueryResult. This type comes from the result of our previous GROQ query, which fetches the headings from Sanity. The NonNullable part ensures that we handle cases where the PostPageQueryResult could be null or undefined.

Now, to get PostPageQueryResult in the first place, we use Sanity’s Typegen CLI tool. This tool automatically generates TypeScript types for all the content whether it’s from queries or document schemas from Sanity. This is super helpful because it ensures that everything we’re working with is strongly typed and consistent with the data we’re pulling.

Next, we define the type for each node in the tree structure. Each node represents a heading in our content. It includes the heading's text, a slug (which we’ll use to link to that section), and an optional children array for nested headings.

By setting up these types, we’ll have a clear and organized way to structure our TOC and manage headings.

Next, we need to transform the flat array of heading blocks into a nested hierarchical tree structure. This will allow us to render a TOC with proper nesting in React. Here’s a function that accomplishes this:

sanity-toc.tsx
import slugify from "slugify";

export function nestHeadings(blocks: Headings): TreeNode[] {
  const treeNodes: TreeNode[] = [];
  const stack: { node: TreeNode; level: number }[] = [];

  blocks.forEach((block) => {
    if (!block.style || !block.children) return;

    const level = parseInt(block.style.replace('h', ''), 10);

    const text = block.children.map((child) => child.text || '').join(' ') || 'Untitled';

    const treeNode: TreeNode = {
      slug: slugify(text),
      text,
      children: [],
    };

    while (stack.length > 0) {
      const topStack = stack[stack.length - 1];

      if (topStack && topStack.level < level) break;

      stack.pop();
    }

    if (stack.length > 0) {
      const parentNode = stack[stack.length - 1]?.node;
      if (parentNode && !parentNode.children) {
        parentNode.children = [];
      }
      parentNode?.children?.push(treeNode);
    } else {
      treeNodes.push(treeNode);
    }
    
    stack.push({ node: treeNode, level });
    
  });

  return treeNodes;
}

This function organizes your headings into a tree structure where each heading node can have children based on its level, creating a hierarchical TOC.

Now that we have our hierarchical TOC, we can render it in a React component. Here’s a simple example of how to do that:

sanity-toc.tsx
export function RenderToc({
  elements,
  level = 1,
}: {
  elements: TreeNode[];
  level?: number;
}) {
  return (
    <ul
      className={cn('space-y-2 text-sm font-semibold', {
        'ml-4 list-disc space-y-1 font-normal': level > 1,
        'space-y-3.5 border-l pl-4': level === 1,
      })}
    >
      {elements.map((el) => (
        <li
          key={el.text}
          className={cn({
            '[&:first-child]:mt-2': level > 1,
          })}
        >
          <Link href={`#${el.slug}`} className="hover:underline hover:underline-offset-4">
            {el.text}
          </Link>
          {el.children && <RenderToc elements={el.children} level={level + 1} />}
        </li>
      ))}
    </ul>
  );
}

export function Toc({ headings, title }: { headings: Headings; title?: string }) {
  return (
    <section className="flex max-w-sm flex-col">
      <h2 className="z-0 mb-4 pb-1.5 font-bold md:sticky md:top-0">
        {title ?? 'Content'}
      </h2>
      <nav className="flex gap-4">
        <RenderToc elements={nestHeadings(headings)} />
      </nav>
    </section>
  );
}

Let's break down what’s happening with these two components in a way that’s easy to follow.

The first one, RenderToc, is responsible for rendering our Table of Contents. It takes in a list of headings, already nested into a hierarchical structure, and a level parameter that tells us how deep into the nesting we are, starting at level 1. This component maps over the headings and renders each one as a list item, complete with a clickable link that jumps to the corresponding section of the page.

The magic happens with the recursion. If a heading has any children, meaning it has subheadings beneath it, RenderToc calls itself, passing in those child elements and incrementing the level by one. This recursive call ensures that subheadings are nested properly under their parent heading, and the process continues until there are no more children. That’s how we end up with a nicely structured, multi-level TOC.

The second component, Toc, is a simple wrapper that ties everything together. It takes the full list of headings and an optional title, displays the title at the top, and then hands off the job of rendering the actual TOC to RenderToc. Essentially, Toc is the outer shell that kicks off the process, while RenderToc handles the heavy lifting of building the tree structure.

Now coming to the cn function. If you’re familiar with shadcn, you might already know how this works. But if not, here’s the gist: cn merges multiple Tailwind CSS class names, cleans up duplicates, and removes any null or undefined values. Here is how it looks as reference:

lib/utilities/cn
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Now that everything is set up, you can use the TOC component like this:

blog/[slug]/page.tsx
import { postPageQuery } from '@/sanity/lib/queries';
import { PostPageQueryResult } from "@/sanity/sanity.types"
import { Toc } form "@/components/sanity-toc"

export default function PostPage({ params: { slug } }: { params: { slug: string } }) {
  
  const post = await client.fetch<PostPageQueryResult>(postPageQuery, { slug });

  return (
    <main>
      <Toc headings={post.headings} title="Post content" />
    </main>
  );
}

So, here’s what it looks like in the end:

Post

And If you want to keep things simple and focus only on h2 headings, you can easily modify your query like this:

sanity/lib/queries.ts
import { groq } from 'next-sanity';

export const postPageQuery = groq`
*[_type == "post" && slug.current == $slug][0] {
  ...,
  "headings": content[style in [ "h2" ]]
}
`

And here's how it looks:

Post

To wrap things up, here's the final sanity-toc.tsx file.

sanity-toc.tsx
import { cn } from '@/lib/utilities/cn';
import slugify from "slugify";
import { PostPageQueryResult } from '@/sanity/sanity.types';
import Link from 'next/link';

// Define the type for the Table of Contents (ToC)
type Headings = NonNullable<PostPageQueryResult>['headings'];

// Define the type for each node in the tree structure
type TreeNode = {
  text: string;
  slug: string;
  children?: TreeNode[];
};

/**
 * Transforms a flat array of blocks into a nested hierarchical tree structure.
 *
 * This function processes a list of heading blocks, organizing them into a tree structure
 * where each node can have multiple children based on their heading levels. The result is
 * a hierarchical representation of the document's table of contents.
 *
 * Algorithm:
 * 1. Initialize an empty array for the top-level nodes (treeNodes).
 * 2. Initialize an empty stack to keep track of current nodes and their levels.
 * 3. Iterate over each block:
 *    - Extract the heading level from the block's style.
 *    - Create a new tree node with the heading's text and level.
 *    - Adjust the stack to maintain the correct hierarchy:
 *      - Pop nodes from the stack while the top node's level is greater than or equal to the current level.
 *    - Determine the parent node from the top of the stack (if available).
 *    - Add the new node to the parent node's children or to the top-level nodes if no parent node exists.
 *    - Push the new node and its level onto the stack for future nesting.
 * 4. Return the array of top-level nodes.
 *
 * @param blocks - The flat list of heading blocks to transform.
 * @returns - A nested list of tree nodes representing the hierarchical structure.
 */

export function nestHeadings(blocks: Headings): TreeNode[] {
  // Array to hold the top-level nodes of the tree
  const treeNodes: TreeNode[] = [];

  // Stack to maintain the current path of nodes and their levels
  const stack: { node: TreeNode; level: number }[] = [];

  // Iterate over each block to build the tree structure
  blocks.forEach((block) => {
    // Skip blocks without style or children
    if (!block.style || !block.children) return;

    // Extract heading level from block style (e.g., 'h2' -> 2)
    const level = parseInt(block.style.replace('h', ''), 10);

    // Extract heading text from block children
    const text = block.children.map((child) => child.text || '').join(' ') || 'Untitled';

    // Create a new tree node for the current heading
    const treeNode: TreeNode = {
      slug: slugify(text),
      text,
      children: [],
    };

    // Adjust the stack to ensure the correct hierarchy
    while (stack.length > 0) {
      const topStack = stack[stack.length - 1];

      // If the top node's level is less than the current level, stop popping
      if (topStack && topStack.level < level) break;

      // Remove the top node from the stack if it does not fit the current level
      stack.pop();
    }

    // Determine the parent node from the stack (if any)
    if (stack.length > 0) {
      const parentNode = stack[stack.length - 1]?.node;
      if (parentNode && !parentNode.children) {
        // Ensure the parent node has a children array
        parentNode.children = [];
      }
      // Add the new node to the parent node's children
      parentNode?.children?.push(treeNode);
    } else {
      // If no parent node, add the new node as a top-level node
      treeNodes.push(treeNode);
    }

    // Push the new node and its level onto the stack for future nesting
    stack.push({ node: treeNode, level });
  });

  // Return the top-level nodes of the tree
  return treeNodes;
}

export function RenderToc({
  elements,
  level = 1,
}: {
  elements: TreeNode[];
  level?: number;
}) {
  return (
    <ul
      className={cn('space-y-2 text-sm font-semibold', {
        'ml-4 list-disc space-y-1 font-normal': level > 1,
        'space-y-3.5 border-l pl-4': level === 1,
      })}
    >
      {elements.map((el) => (
        <li
          key={el.text}
          className={cn({
            '[&:first-child]:mt-2': level > 1,
          })}
        >
          <Link href={`#${el.slug}`} className="hover:underline hover:underline-offset-4">
            {el.text}
          </Link>
          {el.children && <RenderToc elements={el.children} level={level + 1} />}
        </li>
      ))}
    </ul>
  );
}

export function Toc({ headings, title }: { headings: Headings; title?: string }) {
  return (
    <section className="flex max-w-sm flex-col">
      <h2 className="z-0 mb-4 pb-1.5 font-bold md:sticky md:top-0">
        {title ?? 'Content'}
      </h2>
      <nav className="flex gap-4">
        <RenderToc elements={nestHeadings(headings)} />
      </nav>
    </section>
  );
}

Now that our TOC is ready, we might want to create linkable headers so that readers can jump straight to specific sections. To generate unique IDs for our headers, we can set this up within our Portable Text components. Here’s how we can do it together:

First, we’ll import the necessary functions from the @portabletext/react library and the slugify package, which will help us create those unique IDs from our header text.

Here’s the code we’ll use:

portabletext.tsx
import { 
  PortableText as PortableTextReact, 
  toPlainText, 
  PortableTextComponents,
  PortableTextProps
} from '@portabletext/react';
import slugify from 'slugify';

const components: PortableTextComponents = {
  block: {
    h2: ({ children, value }) => {
      // `value` is the single Portable Text block for this header
      const slug = slugify(toPlainText(value));
      return <h2 id={slug}>{children}</h2>;
    },
    // You can add similar setups for h1, h3, etc.
  },
};


export function PortableText({
  components = components,
  ...props
}: PortableTextProps) {
  return (
    <PortableTextReact
      components={ components }
      {...props}
    />
  );
}

And here’s how we can use it in our post page:

blog/[slug]/page.tsx
import { postPageQuery } from '@/sanity/lib/queries';
import { PostPageQueryResult } from "@/sanity/sanity.types"
import { Toc } form "@/components/sanity-toc"
import { portableText } from "@/components/portabletext"

export default function PostPage({ params: { slug } }: { params: { slug: string } }) {
  
  const post = await client.fetch<PostPageQueryResult>(postPageQuery, { slug });

  return (
    <main>
      <Toc headings={ post.headings } title="Post content" />

      <PortableText value={ post.content } />
    </main>
  );
}

So, whether you’re creating a blog, a documentation site, or any other content-driven project, this setup will serve you well. Now it’s time to implement your own table of contents—you’ll be amazed at how easy it is to scale and manage your content! Happy coding!