Have you ever spent hours crafting the perfect long-form content in Sanity Studio, only to discover that your carefully structured blog posts lack the navigation tools readers expect? Without a table of contents, readers often struggle to navigate lengthy articles, leading to higher bounce rates and reduced engagement with your content.
Unlike platforms such as WordPress or Notion that offer built-in table of contents functionality, Sanity CMS requires a custom implementation to extract headings from Portable Text content and create an interactive navigation component. This apparent limitation actually becomes a strength, allowing you to build a TOC experience perfectly tailored to your content strategy and design system.
When you create a new blog post with multiple H2 headings after an introduction, you rightfully expect readers to easily jump between sections. However, without proper navigation aids, even well-structured content can feel overwhelming and difficult to consume.
This comprehensive tutorial will transform your Sanity-powered blog from a basic content display into an engaging, navigable experience that keeps readers engaged throughout your entire article. You'll learn to automatically extract headings from Portable Text, implement smooth scrolling navigation, and add visual indicators for the currently active section.
By the end of this tutorial, you'll be able to:
Prerequisites:
Sanity's Portable Text provides incredible flexibility for rich content creation, but this flexibility comes with the challenge of extracting structured information for navigation purposes. Unlike traditional HTML where you can simply query for heading elements, Portable Text stores content as an array of block objects with style properties.
The core challenge lies in bridging the gap between Sanity's structured content format and the DOM elements that readers interact with. We need to:
Consider this typical Portable Text structure:
{ "content": [ { "_type": "block", "style": "h2", "children": [ { "_type": "span", "text": "Getting Started with Implementation" } ] }, { "_type": "block", "style": "normal", "children": [ { "_type": "span", "text": "This section covers the basics..." } ] } ]}
Our solution must traverse this structure, extract meaningful heading text, and create a seamless connection between the table of contents and the rendered HTML elements.
Our table of contents implementation consists of three interconnected components working together to create a seamless navigation experience:
1. Data Extraction Layer We'll modify our Sanity queries to extract heading information using GROQ's powerful filtering capabilities. The query content[style == "h2"].children[0].text
efficiently extracts all H2 heading text during the initial data fetch.
2. Component Architecture
3. Interactive Features
This approach ensures that your table of contents remains synchronized with your content while providing an optimal user experience across all devices.
Before implementing the frontend components, we need to modify our Sanity queries to extract heading information efficiently. This approach is more performant than parsing Portable Text on the client side.
First, ensure your blog schema includes a content field that uses Portable Text:
export default { name: 'blog', title: 'Blog Post', type: 'document', fields: [ { name: 'title', title: 'Title', type: 'string', }, { name: 'slug', title: 'Slug', type: 'slug', options: { source: 'title', }, }, { name: 'content', title: 'Content', type: 'array', of: [ { type: 'block', styles: [ { title: 'Normal', value: 'normal' }, { title: 'H1', value: 'h1' }, { title: 'H2', value: 'h2' }, { title: 'H3', value: 'h3' }, { title: 'Quote', value: 'blockquote' }, ], }, ], }, ],}
Now, modify your blog post query to extract H2 headings automatically:
export const blogPostQuery = ` *[_type == "blog" && slug.current == $slug][0] { title, slug, publishedAt, excerpt, content, "headings": content[style == "h2"].children[0].text, "author": author->{ name, image } }`;
The key addition is the "headings"
field, which uses GROQ's filtering syntax to extract text from all blocks with style == "h2"
. This query specifically targets the first child's text content, which contains the heading text in Portable Text's structure.
If you want to support multiple heading levels (H2, H3, H4), you can create a more sophisticated query:
export const blogPostWithNestedHeadingsQuery = ` *[_type == "blog" && slug.current == $slug][0] { title, slug, publishedAt, excerpt, content, "headings": content[style in ["h2", "h3", "h4"]] { "text": children[0].text, "level": style }, "author": author->{ name, image } }`;
This enhanced query returns both the heading text and its level, allowing you to create a hierarchical table of contents with proper indentation.
You can test your enhanced query in Sanity Studio's Vision tool to ensure it returns the expected heading data:
*[_type == "blog"][0] { title, "headings": content[style == "h2"].children[0].text}
The result should look like this:
{ "title": "Your Blog Post Title", "headings": [ "Introduction to the Topic", "Main Implementation Steps", "Advanced Configuration", "Troubleshooting Common Issues" ]}
Now we'll build the interactive table of contents component that handles navigation, active state tracking, and user interactions.
ContentWrap helps companies save thousands on their Sanity bills through smart caching strategies and optimized API usage.
Create a new file for the TableOfContents component:
'use client';import Link from 'next/link';import { useEffect, useState } from 'react';import slugify from 'slugify';import { cn } from '@/lib/utils';type TableOfContentsProps = { headings: string[]; className?: string;};export function TableOfContents({ headings, className }: TableOfContentsProps) { const [activeId, setActiveId] = useState<string>(); // We'll implement the intersection observer logic here if (!headings?.length) return null; return ( <div className={cn('sticky top-20', className)}> <h4 className="mb-4 text-sm font-semibold text-gray-900"> In this article </h4> <nav aria-label="Table of contents"> <ol className="space-y-1"> {headings.map((heading) => { const id = slugify(heading, { lower: true, strict: true }); return ( <li key={heading} style={{ animationDelay: `${index * 50}ms`, }} className={cn( styles.item, isActive && styles.activeItem, 'animate-fade-in-up' )} > <Link href={`#${id}`} onClick={(e) => handleClick(e, id)} className={cn( styles.link, isActive && styles.activeLink, 'relative group' )} > <span className="relative z-10">{heading}</span> {isActive && ( <span className="absolute left-0 top-0 w-1 h-full bg-blue-500 rounded-r-sm transform scale-y-0 group-hover:scale-y-100 transition-transform origin-center" /> )} </Link> </li> ); })} </ol> </nav> </div> );}
The PortableText component needs to generate consistent heading IDs that match those used in the TableOfContents
component. This ensures proper synchronization between navigation and content.
Update your existing PortableText component to include heading ID generation:
import { toPlainText } from '@portabletext/react';import { type PortableTextComponents, PortableText as SanityPortableText,} from '@portabletext/react';import React, { type ReactElement } from 'react';import { PortableTextObject } from 'sanity';import slugify from 'slugify';import { cn } from '@/lib/utils';interface PortableTextProps { className?: string; value?: PortableTextObject[]; includeTableOfContents?: boolean; isCondensed?: boolean;}export function PortableText({ className, value, includeTableOfContents = false, isCondensed = false,}: PortableTextProps): ReactElement | null { if (!value) { return null; } const components: PortableTextComponents = { block: { h1: ({ children, value }) => ( <h1 className={cn( 'text-4xl font-semibold mt-12 mb-6', isCondensed && 'text-3xl mt-8 mb-4' )} > {children} </h1> ), h2: ({ children, value }) => ( <h2 id={includeTableOfContents ? slugify(toPlainText(value), { lower: true, strict: true }) : undefined} className={cn( 'text-3xl font-semibold mt-12 mb-6', isCondensed && 'text-2xl mt-8 mb-4' )} > {children} </h2> ), h3: ({ children, value }) => ( <h3 className={cn( 'text-2xl font-semibold mt-8 mb-4', isCondensed && 'text-xl mt-6 mb-3' )} > {children} </h3> ), normal: ({ children }) => ( <p className={cn( 'text-gray-700 leading-relaxed mb-6', isCondensed && 'mb-4' )}> {children} </p> ) } }; return ( <div className={cn('prose', className)}> <SanityPortableText components={components} value={value} /> </div> );}
Consistent Slug Generation: We use the same slugify configuration ({ lower: true, strict: true }
) in both components to ensure IDs match perfectly.
Conditional ID Generation: The includeTableOfContents
prop controls whether heading IDs are generated, allowing you to use the same component for content that doesn't need TOC functionality.
Accessibility Considerations: The heading structure maintains proper semantic hierarchy (h1
→ h2
→ h3
) which is crucial for screen readers and SEO.
toPlainText Utility: This Portable Text utility extracts clean text from complex block structures, handling nested formatting and special characters.
The implementation handles several edge cases automatically:
Examples of headings that are properly handled:"Getting Started" → "getting-started""API & Integration" → "api-integration" "Step 1: Setup" → "step-1-setup""What's Next?" → "whats-next"
The strict: true
option in slugify removes special characters and ensures URL-safe IDs, while lower: true
maintains consistency.
Now we'll integrate the TableOfContents component into your blog post page layout, ensuring proper responsive behavior and visual hierarchy.
Create or update your blog post page to include the table of contents:
import { notFound } from 'next/navigation';import { sanityFetch } from '@/lib/sanity/client';import { blogPostQuery } from '@/lib/sanity/queries';import { PortableText } from '@/components/PortableText';import { TableOfContents } from '@/components/TableOfContents';interface BlogPostPageProps { params: { slug: string; };}export default async function BlogPostPage({ params }: BlogPostPageProps) { const post = await sanityFetch({ query: blogPostQuery, params: { slug: params.slug }, }); if (!post) { notFound(); } // Determine if we should show the table of contents const showTOC = post.headings && post.headings.length >= 3; return ( <article className="relative"> {/* Article Header */} <header className="mb-12"> <div className="max-w-4xl mx-auto px-4"> <h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-4"> {post.title} </h1> {post.excerpt && ( <p className="text-xl text-gray-600 leading-relaxed"> {post.excerpt} </p> )} {post.author && ( <div className="flex items-center mt-8 pt-8 border-t border-gray-200"> <div> <p className="font-medium text-gray-900">{post.author.name}</p> <p className="text-gray-600"> {new Date(post.publishedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', })} </p> </div> </div> )} </div> </header> {/* Main Content */} <div className="max-w-7xl mx-auto px-4"> <div className={cn( 'grid gap-8', showTOC ? 'lg:grid-cols-[1fr_280px]' : 'lg:grid-cols-1' )}> {/* Article Content */} <div className="min-w-0"> <div className="max-w-4xl"> <PortableText value={post.content} includeTableOfContents={showTOC} className="prose prose-lg max-w-none" /> </div> </div> {/* Table of Contents Sidebar */} {showTOC && ( <aside className="hidden lg:block"> <TableOfContents headings={post.headings} className="ml-8" /> </aside> )} </div> </div> {/* Mobile TOC */} {showTOC && ( <div className="lg:hidden bg-gray-50 border-t border-b border-gray-200 py-6 mb-8"> <div className="max-w-4xl mx-auto px-4"> <TableOfContents headings={post.headings} className="relative top-0" /> </div> </div> )} </article> );}
Congratulations! You've successfully implemented a comprehensive, interactive table of contents system for your Sanity CMS blog. This implementation provides your readers with intuitive navigation tools while maintaining excellent performance and accessibility standards.
Your table of contents implementation now includes:
To evaluate the impact of your TOC implementation, monitor these key metrics:
Consider these advanced features for your next iteration:
Enhanced User Experience:
Content Intelligence:
Advanced Customization:
Integration Opportunities:
Now that you have a robust TOC system, optimize your content creation workflow:
Content Structure Guidelines:
SEO Benefits:
To further enhance your Sanity CMS and content management skills:
Your readers will now enjoy a significantly improved content experience with clear navigation, better content discoverability, and professional-grade functionality that rivals the best content platforms available today.
Learn how to add the missing "Open in New Tab" option to Sanity CMS links. This step-by-step guide shows you how to customize link annotations and properly implement frontend rendering for better content management.
Eliminate timezone headaches in Sanity CMS with the rich-date-input plugin. Learn how to prevent publishing date confusion and ensure consistent datetime display for global teams with this simple yet powerful solution.
In this tutorial, you will learn how to extend Sanity CMS with custom superscript and subscript decorators for block array fields.