DEV Community

linkbruno.com
linkbruno.com

Posted on

Anchor link not working? How to find and fix broken #section links

You click a link like /docs/setup#install and the page loads, but it stays at the top. No error, no 404. The link is broken in a way most tools never notice, because the page itself returns 200 OK.

These broken anchor links are common in documentation, long guides and tables of contents. Here's why they happen, how to debug one, and how to find every broken anchor on a site.

How anchor links work

The part after # is called a fragment. Your browser never sends it to the server. It loads the page, then looks for an element whose id matches the fragment and scrolls to it:

<a href="/docs/setup#install">Install</a>

<!-- on /docs/setup -->
<h2 id="install">Install</h2>
Enter fullscreen mode Exit fullscreen mode

If no element has id="install", the browser simply doesn't scroll. That's the whole failure, and it's silent.

Why anchor links break

The heading was renamed. Many CMSs and static site generators create ids from heading text. Rename "Install" to "Installation" and the id changes from install to installation. Every link to the old one stops working.

The generator changed. Switching Markdown renderers or upgrading one can change how ids are built: getting-started, getting_started or gettingstarted. A site migration can break hundreds of anchors at once.

Case doesn't match. Fragments are case-sensitive. #Install won't match id="install".

Special characters. A heading like "What's new in v2.0?" becomes something unpredictable. Spaces turn into %20 in some links and - in others.

Duplicate ids. If two elements share an id, the browser jumps to the first one, which may not be the one you meant.

Content loads later. If the target is rendered by JavaScript after page load, the browser may look for the id before it exists.

It works, but the header hides it. The page does scroll, but a sticky header covers the heading, so it looks like the link went to the wrong place.

Debug one broken anchor

Open the target page, then the browser console, and check the id directly:

document.getElementById('install');           // null means it doesn't exist
document.querySelectorAll('[id="install"]').length;   // more than 1 means duplicates
Enter fullscreen mode Exit fullscreen mode

If it returns null, find what the id is now:

[...document.querySelectorAll('h1[id],h2[id],h3[id],h4[id]')]
  .map((h) => `${h.id}  ${h.textContent.trim()}`)
  .join('\n');
Enter fullscreen mode Exit fullscreen mode

That lists every heading id on the page. Update the link to match, or add the old id back (see below).

Fix the sticky header problem

If the browser scrolls but the heading ends up under a fixed header, add a scroll margin:

:target,
h2[id], h3[id] {
  scroll-margin-top: 80px; /* height of your header plus a little space */
}
Enter fullscreen mode Exit fullscreen mode

No JavaScript needed. This works in all current browsers.

Find every broken anchor on a site

Regular link checkers strip the fragment and only check that the page loads, so they report these links as fine. You need a check that also reads the target page and looks for the id. Here's a small Python script that does that for a list of pages:

import sys
from html.parser import HTMLParser
from urllib.parse import urljoin, urldefrag, unquote

import requests

class Collector(HTMLParser):
    def __init__(self):
        super().__init__()
        self.ids, self.links = set(), []
    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        for key in ("id", "name"):
            if a.get(key):
                self.ids.add(a[key])
        if tag == "a" and a.get("href"):
            self.links.append(a["href"])

cache = {}
def parse(url):
    if url not in cache:
        p = Collector()
        p.feed(requests.get(url, timeout=15).text)
        cache[url] = p
    return cache[url]

for page in sys.argv[1:]:
    for href in parse(page).links:
        target, frag = urldefrag(urljoin(page, href))
        if not frag or frag == "top" or not target.startswith("http"):
            continue
        if unquote(frag) not in parse(target).ids:
            print(f"MISSING #{frag}  on {target}  (linked from {page})")
Enter fullscreen mode Exit fullscreen mode

Run it with the pages you care about:

pip install requests
python anchors.py https://example.com/docs/ https://example.com/docs/setup
Enter fullscreen mode Exit fullscreen mode

It caches each page so a target linked from many places is fetched once. It also accepts the old name attribute, which browsers still honour for <a name="...">.

Two limits: it reads the HTML the server sends, so ids added by JavaScript won't be found, and it ignores # and #top, which are special cases.

For a whole site, a broken link checker that understands fragments saves you writing the crawler. linkbruno.com reports missing #section anchors alongside 404s and redirect problems.

Make anchors stable

The best fix is to stop ids from changing when headings change.

Set ids explicitly. Most Markdown tools support custom heading ids:

## Installation {#install}
Enter fullscreen mode Exit fullscreen mode

That works in many static site generators and Markdown extensions. Check your renderer's docs for the exact syntax.

Keep the old id when you rename. If you can't use custom ids, add an empty element with the old id next to the heading:

<span id="install"></span>
<h2 id="installation">Installation</h2>
Enter fullscreen mode Exit fullscreen mode

Keep ids short and lowercase. #install survives more edits than #how-to-install-the-cli-on-windows-10.

Avoid duplicates. If you reuse components on one page, prefix their ids.

When the target is rendered by JavaScript

If your page builds its content after load, the browser's built-in jump can fire too early. Scroll manually once the content is ready:

function scrollToHash() {
  const el = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1)));
  if (el) el.scrollIntoView();
}
// call after your content has rendered
renderContent().then(scrollToHash);
Enter fullscreen mode Exit fullscreen mode

Most frameworks with client-side routing have a setting for this, so check yours before adding custom code.

Summary

Broken anchor links return 200, so they hide from status checks. Look up the id in the console to debug one, add scroll-margin-top if a sticky header is in the way, give important headings explicit ids, and check fragments, not just pages, whenever you audit your links.

Top comments (0)