Instantly Create a Clickable Table of Contents in Google Docs with Apps Script

If you work with long Google Docs—guides, reports, lesson plans, ebooks, or AI-generated drafts—navigation quickly becomes painful.

Headings exist… but readers still scroll endlessly.

In this post, we’ll build a smart Apps Script that:

✅ Scans all headings in a document
✅ Automatically generates a clickable Table of Contents
✅ Inserts it at the top of the document
✅ Updates it anytime with one click
✅ Avoids duplicates and broken formatting

No add-ons. No external APIs. Just clean, reliable Apps Script.


What We’re Building

A custom menu in Google Docs:

Doc Tools
 ├─ Insert / Update Table of Contents
 └─ Remove Existing Table of Contents

The script:

  • Finds all H1–H4 headings
  • Builds internal anchor links
  • Creates a clean, readable TOC
  • Replaces the old one safely if it already exists

Why This Is Useful

This solves a real, recurring problem:

  • AI-generated documents often lack structure
  • Built-in Docs TOC is limited and fragile
  • Writers want control + automation
  • Editors need TOCs updated repeatedly

This script gives you a foundation you can customize:

  • numbering
  • indentation by heading level
  • styling
  • selective heading inclusion

Full Working Source Code

1️⃣ Add the custom menu

function onOpen() {
  DocumentApp.getUi()
    .createMenu('Doc Tools')
    .addItem('Insert / Update Table of Contents', 'insertOrUpdateTOC')
    .addItem('Remove Existing Table of Contents', 'removeTOC')
    .addToUi();
}

2️⃣ Insert or update the Table of Contents

function insertOrUpdateTOC() {
  const doc = DocumentApp.getActiveDocument();
  const body = doc.getBody();

  removeTOC(); // ensure no duplicates

  const headings = collectHeadings_(body);
  if (headings.length === 0) {
    DocumentApp.getUi().alert('No headings found.');
    return;
  }

  const tocHeader = body.insertParagraph(0, 'Table of Contents');
  tocHeader.setHeading(DocumentApp.ParagraphHeading.HEADING1);

  let insertIndex = 1;

  headings.forEach(h => {
    const p = body.insertParagraph(insertIndex++, h.text);
    p.setIndentStart(h.indent);
    p.setLinkUrl('#' + h.anchor);
  });

  DocumentApp.getUi().alert('✅ Table of Contents inserted.');
}

3️⃣ Collect headings and create anchors

function collectHeadings_(body) {
  const headings = [];

  for (let i = 0; i < body.getNumChildren(); i++) {
    const el = body.getChild(i);
    if (el.getType() !== DocumentApp.ElementType.PARAGRAPH) continue;

    const p = el.asParagraph();
    const heading = p.getHeading();

    if (!isSupportedHeading_(heading)) continue;

    const text = p.getText().trim();
    if (!text) continue;

    const anchor = createAnchor_(text, i);
    p.setLinkUrl('#' + anchor);

    headings.push({
      text,
      anchor,
      indent: headingIndent_(heading)
    });
  }

  return headings;
}

4️⃣ Helpers: heading detection & formatting

function isSupportedHeading_(heading) {
  return [
    DocumentApp.ParagraphHeading.HEADING1,
    DocumentApp.ParagraphHeading.HEADING2,
    DocumentApp.ParagraphHeading.HEADING3,
    DocumentApp.ParagraphHeading.HEADING4
  ].includes(heading);
}

function headingIndent_(heading) {
  switch (heading) {
    case DocumentApp.ParagraphHeading.HEADING2: return 18;
    case DocumentApp.ParagraphHeading.HEADING3: return 36;
    case DocumentApp.ParagraphHeading.HEADING4: return 54;
    default: return 0;
  }
}

function createAnchor_(text, index) {
  return text
    .toLowerCase()
    .replace(/[^\w]+/g, '-') +
    '-' + index;
}

5️⃣ Remove the existing TOC safely

function removeTOC() {
  const body = DocumentApp.getActiveDocument().getBody();

  for (let i = body.getNumChildren() - 1; i >= 0; i--) {
    const el = body.getChild(i);
    if (el.getType() !== DocumentApp.ElementType.PARAGRAPH) continue;

    const p = el.asParagraph();
    const text = p.getText().trim();

    if (text === 'Table of Contents') {
      safeRemove_(body, el);
      continue;
    }

    if (p.getLinkUrl() && p.getLinkUrl().startsWith('#')) {
      safeRemove_(body, el);
    }
  }
}

function safeRemove_(body, el) {
  if (body.getNumChildren() === 1) {
    el.asParagraph().setText(' ');
    return;
  }
  body.removeChild(el);
}

How This Works (Conceptually)

  1. Scan document structure
    • Only paragraphs
    • Only heading styles
  2. Generate anchors
    • Predictable
    • Collision-safe
  3. Link TOC entries
    • Internal #anchor links
  4. Indent visually
    • Mirrors document hierarchy
  5. Rebuild safely
    • No duplicates
    • No “last paragraph” errors

How Readers Can Extend This

Encourage readers to build their own version:

  • Add numbering: 1.2.3
  • Limit to H2+ only
  • Style TOC entries
  • Add page-break before TOC
  • Convert to a sidebar tool
  • Bundle as a Workspace add-on
  • Pair with AI-generated outlines