Your Heading Anchors Are a Public API

Someone reads your deployment guide, finds the part that unblocked them, and drops a link in their team’s Slack: /posts/deploying-to-render#step-3-deploy-to-render. Six weeks later you reword that heading to “Step 3: Ship It” because the old one was clumsy.
The link still returns 200. The page still loads. The reader lands at the top of an article they were told contained a specific answer, scrolls, gives up, and concludes the content was removed.
You changed six words of prose. You also deleted a URL.
The ID is a function of the prose
Almost nobody writes heading IDs by hand. Astro, Next.js MDX setups, Docusaurus, and most markdown pipelines generate them with rehype-slug, which delegates to github-slugger. The algorithm is short: lowercase the text, strip a fixed class of punctuation, collapse whitespace to hyphens.
## Step 3: Deploy to Render -> step-3-deploy-to-render
## What about `fetch()`? -> what-about-fetch
## Caching, Briefly -> caching-briefly
That is the whole coupling problem in three lines. The ID is derived from the heading text, which means the URL is derived from the copy. Prose is the part of a codebase everyone edits freely — you fix a typo, tighten a phrase, drop a colon — and every one of those edits is a URL change wearing a costume.
No other part of your site works this way. Nobody renames a route by fixing its grammar.
The failure is invisible by construction
A broken page link is loud. It produces a 404, a server log line, an entry in your analytics, and a hit in whatever link checker runs in CI.
A broken fragment produces none of that, and not by accident. The fragment is never sent to the server. The browser strips everything from the # onward before it issues the request, resolves the target locally against the DOM, and if no element matches that ID it does nothing at all — no console warning, no error event, no scroll.
Follow the consequences:
- Your host sees
GET /posts/deploying-to-renderand returns 200. There is nothing anomalous to log. - Your analytics, self-hosted or not, sees a perfectly normal pageview.
- The
Refererheader sent onward to other sites has the fragment stripped as well, so even inbound referrers cannot tell you which anchors people actually use. - Your internal link checker almost certainly skips fragments. Most of them, ours included, treat a
#-only link as trivially valid and strip the fragment before resolving a path — because checking it properly means parsing the target page’s ID set, not just requesting the page.
So the one class of link on your site that breaks from ordinary editing is also the only class that generates zero telemetry when it does.
The worst case is not a dead anchor
github-slugger deduplicates within a document by appending a counter in document order. Two sections called “Setup” produce setup and setup-1.
Now add a third Setup section at the top, because you reorganised the article. The generated IDs shift down the page:
Before After
----------------------------- -----------------------------
## Setup -> setup ## Setup (new) -> setup
## Install -> install ## Setup (old) -> setup-1
## Setup -> setup-1 ## Install -> install
## Setup -> setup-2
Every inbound link to #setup now lands on a section written this morning. Every link to #setup-1 lands on the section that used to be #setup.
This is worse than a dead link, because a dead link fails visibly — the reader lands at the top of the page and knows something is off. A renumbered anchor delivers them, confidently and with a smooth scroll, to the wrong content. They have no reason to doubt it.
It also means an anchor’s stability depends on headings you did not touch, somewhere else in the same document.
Your table of contents hides all of this
Every in-page table of contents I have seen is generated from the same heading tree in the same build. Rename a heading and the ToC entry and its target both change, together, always consistent.
So the surface you look at every day is the one surface that cannot break. What breaks is the set of links you cannot see:
- Links from other pages on your own site, hand-written in markdown months ago.
- Links from your docs, your README, your changelog, your release notes.
- Links in issue trackers, Slack threads, Stack Overflow answers, and other people’s blog posts.
Only the first is discoverable from inside your repo, and only if your link checker resolves fragments. The third category is the one that actually matters, and it is entirely outside your control — which is precisely what makes it a public API.
Host redirects cannot fix this
The reflex when a URL dies on a static site is to add a redirect rule. That reflex is useless here, for the same reason the breakage is silent: the fragment never reaches the host, so the host cannot match on it or rewrite it. A _redirects file, a Render rewrite rule, a CDN edge function — none of them can see #step-3-deploy-to-render. There is no request to intercept.
Fragment repair happens in exactly one place: the page itself, after it loads. Which means it belongs in your build output, not your hosting config.
Fix 1: author the IDs that deserve to be stable
rehype-slug only assigns an ID to headings that do not already have one. Give a heading an explicit ID and the generator leaves it alone, permanently, no matter how you reword the text.
Base markdown has no syntax for this, so you need a plugin — remark-heading-id is the common choice, and it gives you the familiar {#custom-id} suffix:
## Step 3: Ship It {#step-3-deploy}
The heading now reads however you like. The URL does not move.
Do not do this to every heading in every post; that is bookkeeping with no payoff for a narrative essay nobody deep-links into. Do it for content that gets referenced in pieces: numbered procedures, configuration references, FAQ entries, error-message explanations, anything you have ever pasted a fragment link to. Those headings are interface. Version them like interface.
Fix 2: make the anchor set a build artifact
The generated IDs already exist in dist/. They are simply never treated as output worth checking. Extract them:
// scripts/anchor-manifest.mjs
import { readdir, readFile, writeFile } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
const DIST = 'dist'
const HEADING = /<h[2-4]\b[^>]*\bid="([^"]+)"/g
async function* pages(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) yield* pages(full)
else if (entry.name === 'index.html') yield full
}
}
const manifest = {}
for await (const file of pages(DIST)) {
const segments = relative(DIST, file).split(sep).slice(0, -1)
const html = await readFile(file, 'utf8')
const ids = [...html.matchAll(HEADING)].map((m) => m[1])
if (ids.length) manifest['/' + segments.join('/')] = ids
}
await writeFile('anchors.json', JSON.stringify(manifest, null, 2) + '\n')
Yes, that is a regular expression against HTML. It is acceptable here and only here: this is not arbitrary input, it is markup your own generator emitted in a shape you control. If it still makes you itch, swap in a parser — the point is the manifest, not the extraction.
Commit anchors.json. Then fail the build when an anchor disappears:
const previous = JSON.parse(await readFile('anchors.json', 'utf8'))
const retired = []
for (const [route, ids] of Object.entries(previous)) {
const current = new Set(manifest[route] ?? [])
for (const id of ids) if (!current.has(id)) retired.push(route + '#' + id)
}
if (retired.length) {
console.error('Retired anchors:\n' + retired.map((a) => ' ' + a).join('\n'))
process.exit(1)
}
Wire it in after astro build. The gate does not forbid renaming headings — it forbids renaming them silently. To get back to green you either restore the ID explicitly or record an alias, and both are deliberate acts with a diff attached.
This is the same trade the rest of a good static pipeline makes: move the failure from a reader’s browser six weeks from now to your terminal, right now.
Fix 3: alias what you retire
For anchors you genuinely want to move, ship a map and resolve it in the page:
const ALIASES = {
'/posts/deploying-to-render': {
'step-3-deploy-to-render': 'step-3-deploy',
},
}
const page = ALIASES[location.pathname.replace(/\/$/, '')]
const hash = decodeURIComponent(location.hash.slice(1))
if (page && hash && page[hash] && !document.getElementById(hash)) {
const target = document.getElementById(page[hash])
if (target) {
history.replaceState(null, '', '#' + page[hash])
target.scrollIntoView()
}
}
Three details matter. It acts only when the requested ID is genuinely absent, so a legitimate anchor is never hijacked. It uses replaceState, so the corrected URL is what gets copied and shared onward. And it belongs inline at the end of the document — a retired anchor should cost zero extra requests, and the correction should land before the reader starts scrolling.
Generate the map from the build gate’s output rather than maintaining it by hand. If you are typing entries into it manually, you have rebuilt the original problem one level up.
The takeaway
Every heading you publish is a URL you published. Generated IDs make that URL a function of your prose, so routine copy-editing quietly retires routes — and because fragments never touch the server, nothing in your stack will ever tell you it happened.
Pick the headings that are genuinely interface, give them IDs you chose, and put the rest behind a gate that makes a disappearing anchor as loud as a disappearing page. The alternative is not that nothing breaks. The alternative is that it breaks in someone else’s Slack thread, and you never hear about it.

