Command Palette

Search for a command to run...

How to Generate a Markdown Table of Contents (GitHub-Compatible Anchors)

How to Generate a Markdown Table of Contents (GitHub-Compatible Anchors)

T
Toolz Team
|Aug 23, 2026|16 min read

Part of the Docs & Notes collection

The first time a README of mine crossed a thousand lines, I did what everyone does: I scrolled. Then I scrolled some more. Somewhere around the fourth pass looking for the "Deployment" section, I gave up and started hand-writing a table of contents at the top of the file. That worked until I renamed a heading, forgot to update the link, and shipped a README where "Configuration" pointed at nothing. A broken in-page link in your own documentation is a small thing, but it is the kind of small thing that tells a reader nobody is minding the store.

I build toolz.dev, a collection of browser-based developer utilities, and I maintain a lot of Markdown: tool guides, README files, internal specs, and the docs you are reading right now. A table of contents that I have to maintain by hand is a table of contents that will eventually lie. So I built the Markdown TOC Generator to do the boring part correctly every time, and this guide is everything I learned about anchor links while building it.

TL;DR: A Markdown table of contents is a nested list of links that jump to headings on the same page. The links work because each heading gets an automatic anchor id, and the slug that GitHub assigns follows a specific rule: lowercase the text, drop punctuation other than hyphens, and turn spaces into hyphens. Paste your Markdown into the generator, choose which heading levels to include, and copy the list. The tool computes the exact slugs GitHub renders, including the -1 suffix for duplicate headings, so nothing breaks when you paste it back.

What is a Markdown table of contents?

A table of contents in Markdown is not a special syntax. CommonMark defines no such construct, and neither does GitHub Flavored Markdown. It is an ordinary list where every item is a link, and every link points at an anchor inside the same document. When a Markdown renderer like GitHub turns a heading into HTML, it also gives that heading an id attribute. A heading written as ## Getting Started becomes roughly <h2 id="getting-started">Getting Started</h2>. Once that id exists, a link written as [Getting Started](#getting-started) scrolls the page to it.

So a table of contents is just a collection of those links, indented to mirror the heading hierarchy:

- [Getting Started](#getting-started)
  - [Installation](#installation)
  - [Configuration](#configuration)
- [Usage](#usage)

The entire trick lives in one word from that example: the anchor. Get the anchor wrong and the link silently fails, scrolling nowhere. Get it right and the contents block works on GitHub, in most static-site generators, and inside documentation platforms that follow the same convention. The hard part is not writing the list. The hard part is predicting the exact id each renderer will assign, which is why doing it by hand is a losing game on any document that changes.

How are heading anchors actually generated?

GitHub's slug algorithm is deterministic and worth memorizing, because once you know it you can predict every anchor on the page. The steps, in order, are: convert the heading text to lowercase, remove any character that is not a letter, a number, a space, or a hyphen, and then replace each space with a hyphen. That is the whole rule.

The consequences are where people trip. Consider a heading like ## Set Up & Config. Lowercasing gives set up & config. Removing the ampersand (but leaving the spaces around it) gives set up config with two spaces where the & used to be. Turning spaces into hyphens produces set-up--config, with a double hyphen. That double hyphen looks like a mistake, but it is exactly what GitHub renders, so it is exactly what your link needs. A tool that "cleans up" the double hyphen would produce a link that does not resolve.

Punctuation-heavy headings collapse further than you expect. ## C++ Guide becomes c-guide, because both plus signs are stripped and the leftover space becomes a single hyphen. ## What's New? becomes whats-new, because the apostrophe and question mark vanish. Emoji and most symbols disappear entirely. The Markdown TOC Generator applies this rule character for character so the slug it shows you is the id GitHub will build, no guessing involved.

What happens when two headings are the same?

Documents repeat headings. A changelog might have three sections all titled ### Fixed. If every one of them produced the slug fixed, only the first link would work. GitHub solves this by numbering the duplicates: the first Fixed gets fixed, the second gets fixed-1, the third gets fixed-2, and so on in document order. The suffix is appended after the base slug with a hyphen.

This is one of the most common reasons a hand-written table of contents drifts out of sync. You add a second section with a name you already used, the anchor quietly becomes -1, and your old link now points at the wrong place or nowhere at all. The generator tracks every slug it has emitted and applies the same numeric suffix, so repeated headings link to the right occurrence.

What kinds of headings does the tool read?

Markdown has two heading styles, and a complete generator has to read both. The common one is ATX, where a line starts with one to six # characters followed by the heading text. The count of hashes is the level, so # is an H1 and ###### is an H6. The second style is Setext, where a line of text is underlined on the next line with equals signs for an H1 or hyphens for an H2:

Document Title
==============

A Section
---------

Both styles produce headings with anchor ids, so both belong in a table of contents. The tricky part with Setext is telling a real underlined heading apart from a horizontal rule, because a line of hyphens can mean either. The rule the generator uses is that a hyphen underline only counts as a heading when the line directly above it is ordinary paragraph text, not a blank line, a list item, a blockquote, or another block construct. A --- sitting alone with blank lines around it is a thematic break, and it is correctly ignored.

There is one more category to handle, and it is the one that quietly ruins naive tools: headings inside code. If your document contains a fenced code block that shows shell commands, some of those lines will start with # as comments. Those are not headings, and they must never appear in the contents. The generator tracks fenced code blocks (the ones delimited by triple backticks or triple tildes) and skips any # line inside them. A shell comment like # install dependencies in an example stays where it belongs, in the example.

How do I control the depth of the table of contents?

A table of contents that lists every heading down to H6 is not a table of contents, it is a second copy of the document. Most READMEs read best when the contents cover H2 and H3 only, giving readers the major sections and their immediate children without drowning them in detail. The generator lets you set a minimum and a maximum level, and it includes only the headings that fall in that range.

The subtle behavior here is indentation. If you include H2 through H4, the shallowest heading you kept is an H2, and it should sit flush against the left margin rather than indented as though an invisible H1 were above it. The generator measures nesting depth relative to the shallowest heading it actually includes, so a contents block that starts at H2 begins with no indent. This is the difference between a list that looks intentional and one that looks like it lost its first column.

You also get to choose the list marker. An unordered list uses a bullet for every entry, which is the conventional look for a README. An ordered list numbers the entries, and the generator restarts the count within each nesting level so a numbered outline reads correctly rather than counting straight through from one to fifty. Indentation can be two spaces, four spaces, or a tab, depending on what the rest of your document uses.

What about accented and non-Latin headings?

Not every heading is plain English, and the slug rule has to cope. GitHub keeps letters from other alphabets rather than stripping them, so a heading like ## Configuración keeps its accented characters and becomes configuración, and a heading in Cyrillic or Greek keeps those letters too. What gets removed is punctuation and symbols, not letters, regardless of the script. The generator follows the same principle by treating any Unicode letter or digit as a valid slug character, so a multilingual document produces anchors that match what GitHub renders rather than a row of empty links.

This matters more than it first appears. Teams that write documentation in Spanish, German, or Japanese often find that naive slug tools mangle their headings into unusable anchors, because those tools assume ASCII. If your links have ever pointed at nothing on a translated README, a slug that silently discarded the non-ASCII letters is almost certainly why. Generating the contents block with a Unicode-aware tool removes that entire class of broken link, and it means the same document can hold headings in more than one language without any of them losing their anchors.

When should I use a generated TOC versus an automatic one?

Some platforms build a table of contents for you. GitLab supports a [[_TOC_]] token, some wikis inject a contents box automatically, and documentation frameworks like Docusaurus render an on-page outline from your headings without you writing anything. When you are working inside one of those systems, use the built-in feature. It stays current with zero effort because the platform regenerates it on every render.

The generator earns its place everywhere else, and "everywhere else" is a big place. GitHub READMEs do not auto-generate a contents block, so a repository landing page needs a real Markdown list committed into the file. Markdown that gets converted to something else, emailed, pasted into an issue, or rendered by a minimal viewer needs a static contents block because there is no engine to build one on the fly. The table below lays out where each approach fits.

Situation Best approach Why
GitHub README Generated static list GitHub renders heading ids but does not auto-insert a TOC
GitLab wiki or docs [[_TOC_]] token Native, always current
Docusaurus / MkDocs page Built-in outline Framework renders it from headings
Plain Markdown file for export Generated static list No renderer to build one at view time
Issue or pull request description Generated static list Anchors work, but nothing auto-generates the list

The rule of thumb: if the thing that displays your Markdown can build the contents itself, let it. If your Markdown might be read somewhere that cannot, generate the list and commit it. When you are converting between formats, the Markdown to HTML converter and the HTML to Markdown converter pair naturally with a generated contents block, because the anchors survive the round trip.

How does this fit the rest of a Markdown workflow?

A table of contents is one piece of keeping long documents readable, and it works best alongside a few habits. Keep your heading text stable once you have published links to it, because renaming a heading changes its slug and breaks every link that pointed at it. When you do rename, regenerate the contents rather than editing the one link you remember, since a rename often shifts duplicate-suffix numbering further down the file.

Structured content benefits from other tools in the same family. When a document leans on tabular data, the Markdown Table Generator builds correctly aligned pipe tables that a hand-typed table almost never gets right. When you inherit messy HTML that needs to become clean Markdown, or clean Markdown that needs to become HTML, the converters handle the translation while preserving heading structure. And if you are auditing a document for length or keyword balance, the Word Counter gives you the numbers without pasting your draft into anything cloud-based.

Everything runs in the browser, which matters more than it sounds. A README often contains unreleased feature names, internal URLs, or client details, and none of that should be uploaded to a third-party server just to build a list of links. The generator parses your Markdown with client-side JavaScript, so the document never leaves your machine. If privacy in developer tooling is something you think about, the write-up on data privacy and online tools covers why local processing is the right default, and the web developer toolkit rounds up the rest of the utilities I reach for daily.

A quick worked example

Suppose you have this document:

# Payment Service

## Getting Started

### Requirements

### Local Setup

## API Reference

### Authentication

### Errors

## Deployment

Set the range to H2 through H3, choose a bulleted list, and leave anchor links on. The generator produces:

- [Getting Started](#getting-started)
  - [Requirements](#requirements)
  - [Local Setup](#local-setup)
- [API Reference](#api-reference)
  - [Authentication](#authentication)
  - [Errors](#errors)
- [Deployment](#deployment)

The H1 title is excluded because it sits above the range, the H2 sections are flush left, and their H3 children are indented one level. Paste that block just under the title in your README and every entry jumps to its section on GitHub. That is the whole job, done in the time it takes to read this sentence, and it stays correct because a machine computed the slugs instead of you.

Frequently asked questions

How does a Markdown table of contents work?

A Markdown table of contents is a list of links that point to heading anchors within the same page. Each heading in a Markdown document is given an automatic id, and a link written as Section jumps to it. This tool reads your headings, builds the matching anchors, and assembles the nested list for you.

Anchors follow the GitHub slug rule: the heading text is lowercased, punctuation other than hyphens is removed, and spaces become hyphens. "Set Up & Config" becomes the id set-up--config. When two headings produce the same slug, the second gets a -1 suffix, the third -2, and so on, matching how GitHub renders them.

Does it work with GitHub README files?

Yes. The slug algorithm mirrors the one GitHub uses to render heading ids, so the table of contents links resolve correctly inside a README on github.com. Paste your README, choose your heading levels, and drop the generated list below the title.

Can I choose which heading levels appear?

Yes. Set a minimum and maximum level, for example H2 through H4, and only headings in that range are included. Nesting depth is measured relative to the shallowest included heading, so the outline never starts with a large empty indent.

Are headings inside code blocks included?

No. Lines that begin with # inside a fenced code block (delimited by triple backticks or triple tildes) are treated as code, not headings, so example snippets and shell comments never appear in the table of contents.

What is the difference between an ordered and unordered TOC?

An unordered table of contents uses bullet markers such as a hyphen for every entry, while an ordered one uses numbers that increment within each nesting level. Choose ordered when readers benefit from a numbered outline, and unordered for a lighter, more conventional contents block.

Does the tool support Setext headings?

Yes. It reads both ATX headings that start with # and Setext headings, where a line of text is underlined with equals signs for H1 or hyphens for H2. Both styles are converted to anchor links the same way.

Is the Markdown TOC generator free and private?

Yes. It is completely free with no signup and no limits. All parsing happens in your browser using client-side JavaScript, so the Markdown you paste never leaves your device and the tool keeps working offline once the page has loaded.


Comments

0 comments

0/2000 characters

No comments yet. Be the first to share your thoughts!