How to Build a Table of Contents in Markdown with Anchor Links
A table of contents in Markdown is really just a list of links. Each one points at an anchor — a #slug — that your renderer builds automatically from a heading's text. Get the slug rules right and every TOC link jumps straight to its section. Get them wrong, and you're left with dead links the moment a heading changes.
Quick answer
Build the list first: a bullet or numbered list where each item is [Heading text](#heading-slug). The #heading-slug part comes from the matching heading, lowercased, with spaces swapped for hyphens and other punctuation stripped out. None of this is part of the core Markdown format, either — CommonMark's own spec still has no official rule for automatic heading anchors, so the exact behavior depends on whatever renders your file. GitHub, Pandoc, and most static-site generators each implement their own version of the same idea. Close enough that the pattern below works almost everywhere, with a few exceptions noted below.
Step-by-step
Write the slug the way your renderer generates it
GitHub applies four rules, in order: lowercase every letter, replace spaces with hyphens, strip any other whitespace or punctuation, and remove markup formatting so _italics_ becomes italics. Take ## Common Problems (and Fixes) — run it through those rules and you get #common-problems-and-fixes. The parentheses disappear, the space before "and" becomes a hyphen, everything lowercases.
Pandoc's default algorithm lands in nearly the same place, just by a slightly different route. It strips all formatting and non-alphanumeric characters except underscores, hyphens, and periods, turns spaces into hyphens, lowercases the result, then removes any leading characters that aren't letters — an identifier can't start with a digit or punctuation mark. Clean, sentence-case headings with no leading numbers or symbols? Both algorithms land on the same slug. Structuring a longer document with numbered or symbol-prefixed headings instead, it's worth reading how ATX heading levels actually work before building a TOC on top of them.
Build the list of links
Once you know the slug, the TOC itself is ordinary Markdown:
## Table of Contents
- [Quick answer](#quick-answer)
- [Step-by-step](#step-by-step)
- [Common problems and fixes](#common-problems-and-fixes)Each list item is just a normal inline link. The leading # is the only thing that marks it as a "TOC link" instead of an outbound one — it tells the renderer to scroll within the page rather than navigate away. For a longer piece with a dozen-plus sections, decide what actually belongs in the list before you write it. See outlining a long document with headings for keeping a big TOC scannable instead of letting it turn into a second document.
Handle duplicate headings
Two headings with the same text — say, two ## Setup sections in different chapters — get disambiguated automatically. GitHub and Pandoc both append a numeric suffix to each repeat: the first stays #setup, the second becomes #setup-1, the third #setup-2. A hand-written TOC link has to match whichever numbered anchor that specific heading actually resolved to, not just the base slug. Guessing wrong here is probably the most common reason a "correct-looking" TOC link goes nowhere.
Test the links
Click through every TOC entry after writing it, or render the file locally, and confirm each one lands where you expect. The anchor is computed from live heading text at render time — nothing stores it — so it's worth checking again any time you touch a heading the TOC references.
Common problems and fixes
A link points to nothing after you rename a heading. The slug comes from the current heading text, not something saved alongside the link. Rename ## Setup to ## Getting Started and its anchor shifts from #setup to #getting-started; any TOC entry still pointing at #setup breaks without any warning. Update the TOC entry in the same edit as the heading, not after.
The TOC works on GitHub but not somewhere else. Pandoc's default identifier algorithm differs from GitHub's in a few edge cases — leading-character handling and accented text especially — unless you enable its gfm_auto_identifiers extension to match GitHub's behavior directly. If your Markdown gets rendered in more than one place, test the TOC in the actual target renderer. Don't assume it'll port cleanly.
Punctuation or inline code in a heading produces a slug you didn't expect. Take ## Using \grep\ to Search Notes — the backticks and punctuation strip out under the standard rule, landing on #using-grep-to-search-notes, not something with the code markup preserved. When a heading mixes prose and code, write the anchor out and check it rather than guess. And once a document has more than a handful of TOC entries, how Markdown link syntax works starts to matter — inline links are fine for a short TOC, but reference-style links keep a long one easier to scan in the raw file.
Doing this with Carets
Carets is a fast, native Markdown, plain text, and code editor for iPhone, iPad, and Mac — and a long document with a hand-built table of contents is exactly the kind of file it's built for. Markdown and plain text are first-class, so you write the TOC and its headings in the same file, with syntax highlighting that makes a stray backtick or bracket in a heading easy to spot before it produces a slug you didn't expect. Files, projects, and tags keep a growing set of long-form notes or docs organized, so the note holding your TOC conventions doesn't get buried in a pile of unrelated files. And because Carets is native and fast on iOS and Mac rather than a web wrapper, fixing a heading and its matching anchor link takes seconds — worth doing the moment you notice a mismatch instead of leaving it broken until your next full pass. Carets is free to try on the App Store for iPhone, iPad, and Mac.
Frequently Asked Questions
Does every Markdown renderer generate the same anchor slug for a heading?
No. CommonMark's spec has no official rule for automatic heading anchors, so behavior is renderer-specific. GitHub lowercases the heading, replaces spaces with hyphens, and strips other punctuation. Pandoc's default algorithm is close but not identical — it also strips leading non-letter characters and handles accented text differently unless you enable its gfm_auto_identifiers extension.
What happens if two headings in the same document produce the same slug?
Both GitHub and Pandoc disambiguate by appending a numeric suffix to later duplicates — the second "Setup" heading becomes #setup-1, the third becomes #setup-2, and so on. If your table of contents links to a specific occurrence, check which numbered anchor it actually resolved to.
Why did my table of contents link stop working after I edited a heading?
The anchor is generated from the heading text at render time, not stored anywhere. Renaming a heading, or reordering duplicate headings, changes the generated slug — any TOC link pointing at the old text now resolves to nothing. Update the TOC link whenever you touch the heading it targets.
Can I set a custom anchor ID instead of relying on the auto-generated slug?
Some renderers support it. Pandoc accepts {#custom-id} syntax directly after a heading, and there's a strong CommonMark-community consensus favoring that same syntax, though it isn't part of the core CommonMark spec yet. GitHub's renderer doesn't support custom heading IDs — you're limited to its auto-generated slug there.