komal.nakrani
Browse Themes
ThemesCategoriesDocsBlogTips & Tricks
Content & MDX••By Komal Nakrani

Auto-Generated Table of Contents in Astro

Build a table of contents in Astro from the headings array that render() returns: filter by depth, nest h3 under h2, and link to the auto-generated heading ids.

#Astro#Markdown#Content Collections#Navigation

To build a table of contents in Astro, take the headings array returned by render(post). Each heading has a depth, text and slug, and Astro has already added a matching id to the heading in the HTML. Filter to depths 2 and 3, render a list of links to #slug, and you have a working table of contents with no plugin.

Key Takeaways

  • const { Content, headings } = await render(post) gives you every heading.
  • Astro adds id attributes to Markdown headings automatically; slug matches them.
  • Filter by depth to skip h1 and deep levels.
  • Nest h3 under h2 with a small reduce, or keep a flat indented list.

Get the headings

---
// src/pages/blog/[slug].astro
import { getCollection, render } from 'astro:content';
import TableOfContents from '../../components/TableOfContents.astro';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}

const { post } = Astro.props;
const { Content, headings } = await render(post);
---
<article>
  <h1>{post.data.title}</h1>
  <TableOfContents headings={headings} />
  <Content />
</article>

For a post with ## Install and ### Using npm, headings looks like:

[
  { depth: 2, slug: 'install', text: 'Install' },
  { depth: 3, slug: 'using-npm', text: 'Using npm' },
]

A flat table of contents

The simplest version indents by depth with CSS:

---
// src/components/TableOfContents.astro
import type { MarkdownHeading } from 'astro';

interface Props { headings: MarkdownHeading[] }
const { headings } = Astro.props;
const items = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
---
{items.length > 1 && (
  <nav class="toc" aria-label="Table of contents">
    <p class="toc-title">On this page</p>
    <ul>
      {items.map((h) => (
        <li class={`depth-${h.depth}`}>
          <a href={`#${h.slug}`}>{h.text}</a>
        </li>
      ))}
    </ul>
  </nav>
)}

<style>
  .toc ul { list-style: none; padding: 0; }
  .depth-3 { padding-left: 1rem; font-size: 0.925em; }
</style>

The items.length > 1 check hides the box on short posts, where a one-item contents list just adds clutter.

A nested table of contents

Proper nesting (<ul> inside <li>) is better for screen readers. Build a tree first:

// src/utils/toc.ts
import type { MarkdownHeading } from 'astro';

export interface TocItem extends MarkdownHeading { children: TocItem[] }

export function buildToc(headings: MarkdownHeading[]): TocItem[] {
  const toc: TocItem[] = [];
  for (const h of headings.filter((h) => h.depth === 2 || h.depth === 3)) {
    const item = { ...h, children: [] };
    if (h.depth === 2 || toc.length === 0) toc.push(item);
    else toc[toc.length - 1].children.push(item);
  }
  return toc;
}
---
import { buildToc } from '../utils/toc';
const toc = buildToc(Astro.props.headings);
---
<nav aria-label="Table of contents">
  <ul>
    {toc.map((h) => (
      <li>
        <a href={`#${h.slug}`}>{h.text}</a>
        {h.children.length > 0 && (
          <ul>
            {h.children.map((c) => <li><a href={`#${c.slug}`}>{c.text}</a></li>)}
          </ul>
        )}
      </li>
    ))}
  </ul>
</nav>

Smooth scrolling and offset

Two lines of CSS make anchor jumps behave nicely, including under a sticky header:

html { scroll-behavior: smooth; }
:target { scroll-margin-top: 5rem; }

Wrap the first in @media (prefers-reduced-motion: no-preference) to respect user settings.

Highlighting the current section

Active-section highlighting needs a little client JavaScript. An IntersectionObserver in a <script> tag is enough:

<script>
  const links = document.querySelectorAll<HTMLAnchorElement>('.toc a');
  const observer = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      links.forEach((a) => a.classList.toggle('active', a.hash === `#${entry.target.id}`));
    }
  }, { rootMargin: '0px 0px -70% 0px' });

  document.querySelectorAll('article h2[id], article h3[id]').forEach((h) => observer.observe(h));
</script>

Custom heading ids

If you need different ids, a rehype plugin can rewrite them. Astro reads ids from the final HTML, so headings[].slug stays in sync as long as the plugin runs in markdown.rehypePlugins.

Most blog and editorial themes need exactly this on long posts. Reading time comes from the same render() call; see add reading time to Astro posts. For everything else render() returns, read the Astro content collections guide. Heading ids and Markdown processing are covered in the Astro Markdown docs.

FAQ

Does Astro add ids to Markdown headings automatically?

Yes. Every heading rendered from Markdown or MDX gets an id based on its text, and that value is the slug in the headings array.

Can I get headings without rendering the post?

headings comes from render(). On an index page you can parse the raw post.body with a regex for ## lines, but rendering is more reliable.