7 min read
Adding Table of Contents in Nuxt 3 with @nuxt/content
For blogs and documentation sites, having a table of contents makes it easier for users to navigate and jump to sections within a long page. In this comprehensive guide, we'll explore how to implement a dynamic table of contents in Nuxt 3 with the [@nuxt/content](https://content.nuxtjs.org) module.
Overview
Here's what we'll cover:
- What is a table of contents and its benefits
- Different ways to generate and render TOC
- Using Markdown headings for content structure
- Accessing document TOC tree from @nuxt/content
- Building a custom Vue TOC component
- Recursively rendering TOC links
- Scrolling spy to highlight active headings
- Styling and layout options
- Dynamic vs static generation
- TOC for custom document types
- Pagination and TOC together
By the end, you'll be able to add a full-featured table of contents to enhance the experience and navigation in Nuxt docs and blogs.
What is Table of Contents?
A table of contents (TOC) is a list of headings included in a long document or book, with each entry linking to the section it references.
For example, this article has a TOC on the right hand side that links to every major section.
Some benefits of adding a TOC:
- Provides quick navigation to parts of the document
- Breaks up long content into consumable sections
- Improves user experience for scanning and jumping around
- Can significantly aid SEO depending on implementation
TOCs are especially useful for documentation sites, help centers, blogs, reports, and books.
Ways to Generate Table of Contents
There are a few approaches to actually generate and render the table of contents:
1. During Content Creation
The TOC can be manually written during the content authoring process in Markdown or a CMS.
But this requires more effort and maintaining it.
2. Pre-rendered During Build
The TOC is generated pre-build time as static HTML for each document.
Drawback is it needs re-generating on content changes.
3. Dynamic Generation
TOC can be generated dynamically on request by parsing the content.
This allows keeping it always up to date.
For our Nuxt implementation, we'll use option 3 to generate the TOC on the fly.
Using Markdown Headings
For the TOC to automatically generate from content, we need a way to structure sections logically.
Markdown headings provide the perfect mechanism for this.
By adding headings like # Title
and ## Subtitle
in the Markdown, it demarcates semantic sections that can be used to build the TOC tree.
For example:
# Main title
Paragraph...
## Subsection 1
Paragraph...
## Subsection 2
Paragraph...
### Sub-subsection
Paragraph...
We can then iterate through these headings to build links for the TOC.
Accessing TOC Tree with @nuxt/content
The @nuxt/content module provides a way to parse Markdown content and access the calculated TOC tree.
Using the tableOfContents
property from a $content
query, we can get the nested heading structure:
const content = await $content("/docs/...", true).fetch();
const toc = content.tableOfContents;
toc
will be an array of heading objects like:
[
{
id: 'main-title',
text: 'Main title',
level: 1
},
{
id: 'subsection-1',
text: 'Subsection 1',
level: 2
parentId: 'main-title'
},
{
id: 'subsection-2',
text: 'Subsection 2',
level: 2,
parentId: 'main-title'
},
//...
]
This provides all the necessary info to render a navigation tree!
Building a Custom Vue TOC Component
Now that we can get the toc from @nuxt/content
, let's build a reusable Vue component to render it.
Under components/
, create TOC.vue
:
<template>
<div class="toc">
<nuxt-link
v-for="heading in toc"
:key="heading.id"
:to="`#${heading.id}`"
:class="['toc-link', 'level-' + heading.level]"
>
{{ heading.text }}
</nuxt-link>
</div>
</template>
<script>
export default {
props: {
toc: {
type: Array,
default: () => [],
},
},
};
</script>
It simply loops through the toc
prop headings, generates links to the id
, and adds some basic styling.
This gives us a reusable TOC component to render the navigation!
Recursively Rendering Links
But right now we're only displaying the top level headings. We want to recursively render their children too.
We can enhance our component to support nested headings:
<template>
<!-- parent headings -->
<TOCItem v-for="heading in toc" :key="heading.id" :heading="heading" />
</template>
<script>
import TOCItem from "./TOCItem.vue";
export default {
components: {
TOCItem,
},
props: {
// toc data
},
};
</script>
And create TOCItem.vue
to handle rendering a heading recursively:
<template>
<div>
<nuxt-link :to="`#${heading.id}`"> {{ heading.text }} </nuxt-link>
<!-- render child headings -->
<TOCItem
v-for="child in heading.children"
:key="child.id"
:heading="child"
/>
</div>
</template>
<script>
export default {
props: {
heading: {
type: Object,
default: () => {},
},
},
};
</script>
Now our TOC component can render nested headings in the tree 🌳
Scrolling Spy with IDs
As the user scrolls, it's nice to highlight the current active TOC link reflecting the visible heading on screen.
We can achieve this "scrolling spy" behavior by watching the heading ids based on scroll position.
First, let's wrap our links in a <header>
tag with a unique id:
<header :id="`heading-${heading.id}`"></header>
Next we'll watch the scroll position:
data() {
return {
activeId: ''
}
},
watch: {
$route() {
this.updateActiveHeading()
},
activeId(newId) {
if (newId) {
const headingLink = document.getElementById(newId)
headingLink?.scrollIntoView()
}
}
}
And implement updateActiveHeading()
to detect visible heading id:
function updateActiveHeading() {
const headings = document.querySelectorAll("header[id]");
let lastVisibleHeading = null;
headings.forEach((heading) => {
if (heading.offsetTop < window.scrollY + 100) {
lastVisibleHeading = heading;
}
});
this.activeId = lastVisibleHeading ? lastVisibleHeading.id : "";
}
Finally add the active class:
:class="heading.id === activeId ? 'active' : ''"
Now the TOC highlights the current section being viewed!
Styling and Layout
With the core functionality complete, let's quickly add some styling polish.
Some ideas:
- Add borders, spacing, and colors for hierarchy
- Adjust indentation based on nesting level
- Position fixed or relative based on preference
- Mobile responsive handling
- Icon indicators
- Collapsing sections
For example:
.toc {
position: fixed;
max-height: 100vh;
overflow: auto;
padding: 1rem;
}
.toc-link {
display: block;
white-space: nowrap;
overflow: hidden;
color: #333;
}
.toc-link.active {
color: #09f;
}
.toc-link.level-2 {
padding-left: 1rem;
}
/* more nesting levels */
This provides a clean, usable TOC while staying out of the content flow.
Static vs Dynamic TOC
One tradeoff to consider is static vs dynamic TOC generation.
We've implemented it dynamically by fetching the tableOfContents
each request. This ensures it's always up to date if content changes.
But this also requires hydrating Markdown and parsing headings on every page view.
An alternative is to generate it statically during build and cache it. This avoids re-computing TOC each request at the cost of some staleness.
If content changes infrequently, static generation can work well and improve performance.
Handling Custom Document Types
So far we've focused on Markdown content, but @nuxt/content supports many document types including JSON, YAML, CSV etc.
The TOC integration will work out of the box for any content with tableOfContents
data.
But for fully custom document structures, you may want to process the TOC yourself.
For example, here's how to generate TOC from custom JSON documents:
// In asyncData()
const content = await queryContent("/docs").findOne();
const toc = content.data.map((section) => {
return {
id: section.id,
text: section.title,
level: 1,
};
});
return { toc };
As long as you pass the structured toc data to the component, it will handle rendering.
Integrating with Pagination
Often you'll want to combine TOC with pagination when dealing with long lists of content.
The key is to generate the TOC based on the paginated results.
For example:
// Fetch paginated docs
const docs = await $content("/docs")
.only(["_id", "title"])
.paginate(page, 12)
.find();
// Generate TOC from titles
const toc = docs.map((doc) => {
return {
id: doc._id,
text: doc.title,
level: 1,
};
});
return {
docs,
toc,
};
Now different page loads will have a TOC specific to those results.
Wrap Up
And that wraps up how to implement a full-featured table of contents in Nuxt 3 with @nuxt/content!
Here's a quick summary of what we covered:
- Understanding TOC and different generation approaches
- Using Markdown headings for automatic structure
- Accessing the TOC tree from @nuxt/content
- Building a custom recursive Vue TOC component
- Implementing scroll spy and styling
- Static vs dynamic generation tradeoff
- Handling custom document types
- Integrating with paginated content
Some ways to further enhance the TOC component:
- Add smooth scrolling to headings -TOC dropdown on mobile
- Auto-collapse deep levels
- Sync with external router scroll position
- Persist expanded sections
- Search and highlight terms
I hope you found this guide useful for learning how to create navigational TOCs in Nuxt docs and blogs! Let me know if you have any other suggestions for Nuxt content tutorials.
- Overview
- What is Table of Contents?
- Ways to Generate Table of Contents
- Using Markdown Headings
- Accessing TOC Tree with @nuxt/content
- Building a Custom Vue TOC Component
- Recursively Rendering Links
- Scrolling Spy with IDs
- Styling and Layout
- Static vs Dynamic TOC
- Handling Custom Document Types
- Integrating with Pagination
- Wrap Up