Medium wave · 540–1600 kHz60 stn
1580kHz

Your Heading Anchors Are a Public API

A towering brass wall of hundreds of identical keyholes in a fog-filled vault, one lock being silently re-cut by a clockwork arm while the copper key already sailing toward it dissolves into glowing neon purple embers, dark steampunk archive lit by teal filaments and amber gaslight

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-render and returns 200. There is nothing anomalous to log.
  • Your analytics, self-hosted or not, sees a perfectly normal pageview.
  • The Referer header 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:

  1. Links from other pages on your own site, hand-written in markdown months ago.
  2. Links from your docs, your README, your changelog, your release notes.
  3. 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.