How to Make a Table of Contents from Sanity Block Content
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:
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:
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
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:
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:
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:
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:
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:
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:
And If you want to keep things simple and focus only on h2
headings, you can easily modify your query like this:
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:
To wrap things up, here's the final sanity-toc.tsx
file.
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:
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:
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!