Writing Content that produces a good TOC
The TOC you see in <TableOfContents> is not invented by this module — it is generated by @nuxt/content from your Markdown headings. This page shows how the mapping works and how to fix “my TOC is empty.”
The heading hierarchy
In Markdown:
---
title: Installing nuxt-toc <!-- page title: not from a heading -->
---
# Installing nuxt-toc <!-- h1 — the page title in the body (not in TOC) -->
Some intro.
## Installation <!-- h2 — top-level TOC item -->
### Prerequisites <!-- h3 — nested child when depth >= 2 -->
### Via nuxi
## Usage <!-- h2 — new top-level item -->
### Content v3 example <!-- h3 -->
#### Deeper detail <!-- h4 — only when searchDepth/depth ≥ 3–4 -->
## Troubleshooting <!-- h2 -->The resulting page.body.toc (readable with JSON.stringify(page.body.toc, null, 2) in dev):
{
"links": [
{
"id": "installation",
"text": "Installation",
"depth": 2,
"children": [
{ "id": "prerequisites", "text": "Prerequisites", "depth": 3 },
{ "id": "via-nuxi", "text": "Via nuxi", "depth": 3 }
]
},
{
"id": "usage",
"text": "Usage",
"depth": 2,
"children": [
{
"id": "content-v3-example",
"text": "Content v3 example",
"depth": 3,
"children": [{ "id": "deeper-detail", "text": "Deeper detail", "depth": 4 }]
}
]
},
{ "id": "troubleshooting", "text": "Troubleshooting", "depth": 2 }
]
}Rules:
# h1is not included. The TOC starts at## h2.##→depth: 2,###→depth: 3,####→depth: 4, … They become the tree structure.- Content generates
idby slugifying the text ("Foo Bar!"→foo-bar). The heading in HTML and the TOC link share this id, so<a href="#foo-bar">actually jumps.
Two “depth” concepts (beginners mix these up)
| Name | Where it lives | What it controls |
|---|---|---|
Content toc.depth / searchDepth | content.config.ts (defineCollection) or nuxt.config.ts → content.build.markdown.toc | How many heading levels Content extracts into body.toc. If this is 1, even if your Markdown has ###, they never appear in links. |
TOC display depth prop / nuxtToc.depth | src/runtime/utils/limit-toc-depth.ts (limitTocDepth / resolveEffectiveDepth) | How many levels of the already-extracted tree are rendered. Trimmed copy is created; original is not mutated. |
Make both generous enough, then limit display with the prop:
// nuxt.config.ts — extract h2–h4 into body.toc (playgrounds use depth: 4)
export default defineNuxtConfig({
content: {
build: {
markdown: { toc: { depth: 4, searchDepth: 4 } },
},
},
nuxtToc: { depth: 2 }, // but show h2 + h3 by default
})<!-- On a page that needs h4 -->
<TableOfContents :toc="page.body?.toc" :depth="3" />The playground content-v3/nuxt.config.ts:7 uses depth: 4, searchDepth: 4 so the /props demo can toggle display depth freely.
Frontmatter and titles
A Markdown file can have a frontmatter block (--- at the top) with title and other metadata. This title is available as page.title — it is not automatically a heading. Use it as the article <h1> if you like:
<h1 v-if="page">{{ page.title }}</h1>
<ContentRenderer :value="page" />The TOC title (title prop on <TableOfContents>) is independent: <TableOfContents title="On this page" /> controls the small heading above the TOC links — not the page’s h1.
Auto-heading IDs and how to override them
By default each heading gets a slugified id. To customize:
## Custom heading {#my-id}Now the heading is <h2 id="my-id"> and the TOC link is { id: "my-id", text: "Custom heading" }. Use this when two headings would slug to the same id (duplicate text) or when you want stable URLs.
Images, code blocks, and TOC
Only headings generate TOC entries. Other Markdown (images, code fences, lists) does not affect the tree — except that consecutive headings without any body text are still listed.
Why is my TOC empty?
Diagnose in order:
- Are there
##headings at all? A file with only#or only paragraphs haslinks: []by definition. - Is
content.build.markdown.tocdisabled? Some setups settoc: falseinadvertently. - Is the wrong path queried? Log
page.value—page.body?.tocmay benullbecauseroute.pathdoes not match Content’s generated path (content/guide.md→/guide, not/content/guide). Useroute.pathor pass explicitpath="/guide". - Collection mismatch (v3)? A v3 collection filters files via
source. A file outsidesource: 'docs/**'will not be found. - Passing the wrong shape? Ensure you pass
page?.body?.toc(TOC) notpage(document) withoutnormalizeToc’s tolerant fallback being relied on. Explicit is clearer.
The component’s empty handling (TableOfContents.vue:48):
- Pass-in: renders nothing when empty, unless
is-title-shown-with-no-contentistrue(title-only mode). - Auto-fetch: renders “No headings found for /path.” or “No content found …” — see Pass-in vs auto-fetch.
When to change depth vs isSublistShown
- Modern API:
:depth="1"(flat),:depth="2"(default),:depth="3"… Maps directly tolimitTocDepth(toc, depth)(src/runtime/utils/limit-toc-depth.ts:24), trimming the tree and shallow-copying only when truncation is needed. - Legacy API:
:is-sublist-shown="false"forces effective depth to1regardless ofdepth(resolveEffectiveDepth). Keep it only if you upgrade from v2.x where you already use it — otherwise usedepth.
Next: Pass-in vs auto-fetch explains when to reuse the page query versus letting the component fetch, and Styling shows how the rendered nested <ul> maps to CSS.
