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
idattributes to Markdown headings automatically;slugmatches them.- Filter by
depthto skiph1and deep levels.- Nest
h3underh2with 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.