Skip to content

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:

md
---
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):

json
{
  "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:

  1. # h1 is not included. The TOC starts at ## h2.
  2. ## → depth: 2, ### → depth: 3, #### → depth: 4, … They become the tree structure.
  3. Content generates id by 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) ​

NameWhere it livesWhat it controls
Content toc.depth / searchDepthcontent.config.ts (defineCollection) or nuxt.config.ts → content.build.markdown.tocHow 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.depthsrc/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:

ts
// 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
})
vue
<!-- 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:

vue
<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:

md
## 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:

  1. Are there ## headings at all? A file with only # or only paragraphs has links: [] by definition.
  2. Is content.build.markdown.toc disabled? Some setups set toc: false inadvertently.
  3. Is the wrong path queried? Log page.value — page.body?.toc may be null because route.path does not match Content’s generated path (content/guide.md → /guide, not /content/guide). Use route.path or pass explicit path="/guide".
  4. Collection mismatch (v3)? A v3 collection filters files via source. A file outside source: 'docs/**' will not be found.
  5. Passing the wrong shape? Ensure you pass page?.body?.toc (TOC) not page (document) without normalizeToc’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-content is true (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 to limitTocDepth(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 to 1 regardless of depth (resolveEffectiveDepth). Keep it only if you upgrade from v2.x where you already use it — otherwise use depth.

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.

Released under the MIT License.