---
title: "How to Generate a Table of Contents with GROQ"
description: "Create an auto-generated table of contents using GROQ queries and React, complete with anchor links and scroll highlighting for improved content navigation."
---

# How to Generate a Table of Contents with GROQ

![overhead shot of a wooden table filled with colorful post-it notes each labeled "H1" "H2" "H3"](https://cdn.sanity.io/images/cyu7k2r0/production/66a1a5f4690bbf3ff68e242b9e169d659936f05d-1344x896.jpg)

Creating a dynamic table of contents for your content can significantly improve navigation and user experience. In this guide, I'll show you how to implement an auto-generated table of contents using GROQ queries with Sanity and React.

Check out the live demo... literally off to the side on this page 👉 (or at the top on mobile).

## The GROQ Query

The first step is to create a GROQ query that extracts headings from your PortableText content. Here's how to do it:

```javascript
'headings': content[style in ['h2', 'h3', 'h4', 'h5', 'h6']] {
  style,
  'text': pt::text(@)
}
```

This query selectively extracts only H2–H6 blocks from your content, ignoring H1 and other paragraph blocks. The special `pt::text(...)` function converts PortableText objects to plain text strings, while the `@` symbol refers to the root value of the current scope (in this case, the heading blocks).

## Converting Headings to Anchor Links

Once we have our headings, we need to convert them to slugs that can be used as HTML `id` attributes for anchor linking. This allows users to click on a table of contents item and jump directly to that section of the page:

```typescript
export function slug(
  str: string,
  removeLeadingNumberAndHyphen?: boolean = false
) {
  const result = str
    .toLowerCase()
    .normalize('NFD') // Decompose combined characters (é → e + ´)
    .replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks (accents)
    .replace(/[^\w\s-]/g, '') // Remove non-word characters except spaces and hyphens
    .replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens
    .replace(/-+/g, '-') // Collapse multiple hyphens
    .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
    .trim()

  if (removeLeadingNumberAndHyphen) return result.replace(/^\d+-/, '')

  return result
}
```

_File: `src/lib/utils.ts`_

## Implementing the TOC Component

The `toc-item.tsx` component handles the display and functionality of each table of contents item. Let's break down how it works:

- It takes a heading object with `style` and `text` properties.
- Uses the `slug()` utility to convert heading text to valid HTML IDs.
- Implements the Intersection Observer API to highlight the current section as the user scrolls.
- Applies different indentation based on heading level (H3–H6).

## Adding Visual Indicators

The component includes logic to add a CSS class when a heading is in view, allowing you to visually indicate the current section to the user:

```typescript
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        ref.current?.classList.add(css.inView)
      } else {
        ref.current?.classList.remove(css.inView)
      }
    })
  },
  {
    threshold: 1,
    rootMargin: `${document.documentElement.scrollHeight}px 0px -${thresholdHeight}px 0px`,
  }
)
```

_File: `src/ui/table-of-contents/toc-item.tsx`_

You can view the **source code** in the **[SanityPress with Typegen repo](https://github.com/nuotsu/sanitypress/blob/main/src/ui/table-of-contents/toc-item.tsx)**.

## Styling Based on Heading Level

The component applies different indentation based on the heading level, creating a hierarchical visual structure:

```tsx
className={cn(
  'link block py-1 leading-tight',
  'pl-ch': stegaClean(heading.style) === 'h3',
  'pl-2ch': stegaClean(heading.style) === 'h4',
  'pl-3ch': stegaClean(heading.style) === 'h5',
  'pl-4ch': stegaClean(heading.style) === 'h6',
)}
```

_File: `src/ui/table-of-contents/toc-item.tsx`_

## Putting It All Together

With these components in place, your table of contents will automatically:

- Extract headings from your content.
- Generate anchor links for each heading.
- Highlight the current section as users scroll.
- Display a hierarchical structure based on heading levels.

This implementation provides a seamless navigation experience for your users, especially for longer content pages where finding specific sections quickly is important.

You can find the complete implementation in the **[SanityPress with Typegen](https://2023.sanitypress.dev/repo)** starter template, which provides this and many other useful components for building content-rich websites with Sanity and Next.js.

Read more on **[other helpful GROQ queries](https://2023.sanitypress.dev/blog/get-ridiculously-organized-quickly-with-groq)** over on the original SanityPress blog!

![a minimalist table with dirty white plastic bags with the text "Contents", displayed at a contemporary art museum](https://cdn.sanity.io/images/cyu7k2r0/production/497ed58b5bb7db592c9c17e87e15baa585b25fa6-1344x896.jpg "Figure: Table of Contents")
