Agent view · Guides · 2026-09-23
How to find broken internal links and dead anchor links on your site
This is the response this server returns to a client that asks for Accept: text/markdown at https://crawlcheck.io/guides/how-to-audit-internal-links-and-dead-anchors: fetched when you opened this page, shown byte for byte. An assistant, a crawler that negotiates markdown, or anything reading our feed gets exactly this. The markdown leaves signed (RFC 9421, key in our key directory), so a crawler can prove the page it read is the page this server sent; the check is shown below.
| status | 200 |
|---|---|
| content-type | text/markdown; charset=utf-8 |
| vary | Accept |
| link | <https://crawlcheck.io/llms.txt>; rel="describedby"; type="text/plain", <https://crawlcheck.io/.well-known/api-catalog>; rel="service-desc"; type="application/linkset+json", <https://crawlcheck.io/guides/how-to-audit-internal-links-and-dead-anchors>; rel="cite-as", <https://crawlcheck.io/.well-known/http-message-signatures-directory>; rel="http-message-signatures-directory" |
| cache-control | public, max-age=900, stale-while-revalidate=2400 |
| bytes | 20,357 |
| words | 3,195 |
| sha-256 | 0fcafbaf1717e7945e33cac2039fd0496d12a108c1c2dad719c3e3e0e2647cc6 |
| repr-digest | sha-256=:D8r7rxcX55ReM8rCA5/QSW0SoQjBwtrXGcPj4OJkfMY=: ← matches the body shown |
| signature-input | sig1=("@authority";req "@path";req "repr-digest" "content-type");created=1794202206;expires=1794205806;keyid="1hHmE3fo1rg5Ig70wuFtJ5wBc7G-sUTgUJkxyPWFRps";alg="ed25519";nonce="KzjxWVl75KRZxdEEEhBQ-Q";tag="crawlcheck-page" |
| signature | sig1=:PHSke3o+5GbkhOdLwEdflQJYqDKnGoFWWIbG6lnqQjtQC5W8oyePX4LKDfg9svcjwxzMC1HJUdbTXWUBNqFgAw==: |
| verified | yes — signature by key 1hHmE3fo1rg5Ig70… from https://crawlcheck.io/.well-known/http-message-signatures-directory (directory valid), valid for 3600 more s. Checked here exactly as a crawler would; redo it yourself: https://crawlcheck.io/api/tool/wba |
Guides · 2026-09-23 · By [VSNARY | Emmanuel Orta](/author) · 2 views
# How to find broken internal links and dead anchor links on your site
A link audit is an inventory, a fetch, a classification and a second pass over in-page anchors. On our own site, 2,260 link targets came down to four real defects, all from shared templates.
Start from the sitemap, fetch every page, and collect every internal link on those pages. Request the targets the sitemap does not list and record each status. Classify by what the request was: a redirect is working but worth updating, a 400 on an API endpoint without parameters is correct, a 404 on a page link is broken. Then check that every in-page link has a matching id in the rendered page, and trace each defect to the template that generated it.
Check your own site free
Check my site
About a minute. No account, no card, nothing installed.
On this page
- Step 1: how do you build the inventory?
- Step 2: collect every internal link
- What counts as one address?
- Step 3: request what the sitemap does not cover
- Step 4: how do you tell a broken link from a correct error?
- Step 5: how do you find broken anchor links?
- Step 6: trace each defect to its template
- Step 7: decide what redirects to keep
- What did the audit find on our own site?
- What about links to other sites?
- How often should you run it?
Most internal link audits are a crawl and a list of non-200 responses. That finds some broken links, reports others that are not broken, and misses a whole class of dead links entirely: the ones that point at a place on the same page. This guide covers the full method, in the order we ran it on crawlcheck.io, with the pitfalls that produced wrong answers along the way.
**Key points**
- A full link audit is seven steps: inventory, collect, request, classify, check anchors, trace to templates, and update redirected links.
- A status code answers the request that was made: a 400 from an API endpoint linked without its parameters is correct, not broken.
- Links to `#fragments` fetch nothing, so a status-code crawl cannot find dead anchors; they need a crawler that checks fragments, such as Screaming Frog with Crawl Fragment Identifiers switched on, or a check of the rendered page.
- On a templated site most broken links come from shared code, so the fix belongs in the template.
- On crawlcheck.io, 2,260 link targets came down to four real defects, all from two templates.
Status-code crawls cover steps 1 to 3. The judgement in step 4 and the separate check in step 5 are what turn a list of codes into a list of defects.
## Step 1: how do you build the inventory? #
Start from the sitemap. It is the site’s own statement of which pages exist. Fetch it, list every [URL](/glossary/url), and request each one. Every URL in a sitemap should answer 200 on its final hop; a sitemap that lists redirects or errors is itself the first finding. Checking it first also tells you whether it can be trusted as the inventory. [The sitemap audit guide](/guides/xml-sitemap-audit) covers what to look for in the file itself.
## Step 2: collect every internal link #
On each fetched page, collect every `href` that points inside the site: relative paths, and absolute URLs on your own host. Strip fragments and query strings for this pass, and de-duplicate. On crawlcheck.io, 924 pages produced 2,260 distinct targets. Many will already be in the sitemap. The ones that are not are the interesting part: pages the site links to but does not declare, and addresses that should not exist.
Collect from the served HTML, not from the source templates. Links assembled at render time, or included only for certain records, appear only in the output. Skip links inside `<script>` and `<template>` blocks; text that looks like a URL in a script is not a link a reader or crawler follows.
## What counts as one address? #
Before comparing links with the sitemap, normalise them, or the same page counts several times and every variant looks like a separate defect. These are different URLs to a crawler even when they show the same page:
| Variant | Example | What to do |
| --- | --- | --- |
| Scheme | `http://` vs `https://` | link only to https; the http address should redirect |
| Host | `www.` vs the bare domain | link only to the one the site serves; the other should redirect |
| Trailing slash | `/pricing` vs `/pricing/` | use the form the [canonical](/glossary/canonical) tag declares |
| Case | `/Pricing/` | paths are case-sensitive; link in the canonical case |
| Tracking parameters | `?utm_source=…` | never on internal links; they split one page into many URLs |
Every variant that redirects is a working link with a cost; every variant that answers 200 without redirecting is a duplicate page. Both are worth fixing, but neither is a broken link, and an audit that counts them as broken buries the real defects.
## Step 3: request what the sitemap does not cover #
Request each target that is not in the sitemap, with a short timeout, and record the final status and whether it redirected. Run requests in parallel if the site can take it, but keep the count modest: a link audit that trips your own [rate limit](/glossary/rate-limit) produces a list of 429s that are about the audit, not the links. From a terminal, one line per URL is enough:
```
while read u; do
curl -s -o /dev/null -w "%{http_code} %{num_redirects} $u\n" -L --max-time 10 "$u"
done < targets.txt
```
## Step 4: how do you tell a broken link from a correct error? #
A status code answers the request that was made. Six 400s on our own site were documentation linking API addresses; counting them as broken would have tripled the defect count with no defect behind it.
| Answer | Usually means | Action |
| --- | --- | --- |
| 200 | working | none |
| 301, 308 | moved permanently | update the link to the final address |
| 302, 307 | temporary redirect | check it is meant to be temporary |
| 400 on an API endpoint | request needs parameters | correct; exclude from the broken list |
| 401, 403 | login or firewall | check the page is meant to be public |
| 404, 410 on a page link | broken | fix the link or restore the page |
| 429 | the audit was rate-limited | slow down and re-request |
| 5xx | server error | re-request; persistent errors are defects |
On crawlcheck.io, six links answered 400. Every one was an API endpoint linked from documentation as an address, which needs a domain in the request to do anything. A 400 is the correct answer to an incomplete request. [The HTTP status code guide](/guides/http-status-codes-what-a-crawler-actually-gets) covers what each answer tells a crawler.
## Step 5: how do you find broken anchor links? #
A link to #local-pack fetches nothing, so no status-code crawl can flag it. It fails only on pages where the template skipped the section, which is why the check must read the rendered page, not the template.
A link to `#section` works only if the same page contains an element whose `id` matches. Checking this means reading each rendered page, listing every link that starts with `#`, and confirming each target exists. In a browser console, one line does it for the open page:
```
[...document.querySelectorAll('a[href^="#"]')]
.map(a => decodeURIComponent(a.getAttribute('href').slice(1)))
.filter(id => id && !document.getElementById(id))
```
An empty result means every anchor resolves. Run it on the rendered document, not the source: sections drawn only for some records exist in the template but not on the page. On crawlcheck.io, one report carried 38 in-page anchors, and three pointed at sections drawn only for local businesses. Query for any element with the id, not a specific kind of element; our first check looked for a section element with a given id, reported it missing, and was wrong because the id was on a different element. A check narrower than the question it claims to answer produces confident false positives.
Duplicate ids matter here too. If two elements share an id, the link jumps to the first one, which is often not the one intended. A rendered-page check should list ids that appear more than once and are the target of a link.
## Step 6: trace each defect to its template #
Fixing the page that showed the defect fixes one page. Fixing the template fixes every page built from it, including ones the audit has not reached yet. Then test a contrasting page where the link should still work.
On a site built from templates, a broken link is rarely a one-off. It is an assumption in shared code that fails for some input. Our one broken page link came from a table that links every row to a directory profile, used on a page whose single row is deliberately not in the directory. The three dead anchors came from a list that linked every section, drawn or not. Fixing each at the template fixed every page of that kind at once. After the anchor fix, a report for a local business still linked all three sections, and all three were there.
## Step 7: decide what redirects to keep #
A redirected internal link works, but it costs every crawler an extra request and dilutes the signal of which address is canonical. Update internal links to point at final addresses. Keep the redirect for anyone arriving from outside. When you remove a page that was linked, redirect it to its closest replacement rather than letting it 404; [the canonical URL guide](/guides/canonical-url-audit-one-page-one-address) covers keeping one address per page.
## What did the audit find on our own site? #
crawlcheck.io, September 2026. Over two thousand link targets reduced to four real defects, both traced to shared templates, once correct errors were separated from broken links.
| Check | Count | Result |
| --- | --- | --- |
| [Sitemap](/glossary/sitemap) pages fetched | 924 | all 924 answered 200 |
| Distinct internal link targets | 2,260 | collected from those pages |
| Targets outside the sitemap, requested | 1,339 | 7 answered other than 2xx |
| Of those, API addresses linked without parameters | 6 | 400, correct |
| Real broken page link | 1 | 404, fixed |
| In-page anchors on one report | 38 | 3 with no target, fixed |
All four defects came from shared code. The broken page link was a table that links every row to a directory profile, used on the one page whose single row is deliberately not in the directory; that row now links to the evidence it summarises, and the old address redirects. The three dead anchors were section links on reports for sites that are not local businesses, pointing at sections only drawn for local businesses; an unmeasured section is now plain text, and a local business report was checked to confirm its three links still work. One further [dead anchor](/glossary/dead-anchor) we first reported was not dead at all: the id sat on a different kind of element than our check looked for, and the finding was withdrawn before anything changed. All four real defects were fixed the same evening.
## What about links to other sites? #
Outbound links break more often than internal ones, because you control none of the pages they point at. Check them on a slower schedule, monthly is usually enough, and treat their errors with more caution: a 403 or 429 from another site is often its bot protection refusing an automated request, not a dead page. Open any outbound link that fails the automated check in a browser before you remove it, and replace a genuinely dead source with an archived copy or a current equivalent rather than deleting the [citation](/glossary/citation).
## How often should you run it? #
Run the full audit after any change to shared templates, navigation or routing, because those are the changes that break links on many pages at once. Between those, a check of the sitemap and the pages it lists catches most regressions. The method needs nothing beyond a sitemap, an HTTP client and a way to read rendered HTML. A CrawlCheck scan checks the homepage’s links and reads the sitemap on every run, and the accessibility section reports skip links that point at a missing id, which is the same dead-anchor defect in its most visible place.
Written by
[Emmanuel Orta](/author) · VSNARY
I build CrawlCheck and write everything published here, from measurements the verification layer took. Where a post got something wrong, the correction stays in the post.
[Every post, and where I got it wrong →](/author) · [Follow on LinkedIn →](https://www.linkedin.com/in/emmanuelorta)
Share this finding: [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcrawlcheck.io%2Fguides%2Fhow-to-audit-internal-links-and-dead-anchors%3Futm_source%3Dlinkedin%26utm_medium%3Dsocial%26utm_campaign%3Dshare)[X](https://twitter.com/intent/tweet?url=https%3A%2F%2Fcrawlcheck.io%2Fguides%2Fhow-to-audit-internal-links-and-dead-anchors%3Futm_source%3Dx%26utm_medium%3Dsocial%26utm_campaign%3Dshare&text=How%20to%20find%20broken%20internal%20links%20and%20dead%20anchor%20links%20on%20your%20site)[Email](mailto:?subject=How%20to%20find%20broken%20internal%20links%20and%20dead%20anchor%20links%20on%20your%20site&body=https%3A%2F%2Fcrawlcheck.io%2Fguides%2Fhow-to-audit-internal-links-and-dead-anchors)[Follow Emmanuel on LinkedIn](https://www.linkedin.com/in/emmanuelorta)
## Questions this post answers
### What is the best starting point for an internal link audit?
The sitemap. It is the site's own list of pages, so fetch every URL in it first, then collect the links on those pages.
### Is a 400 response always a broken link?
No. An API endpoint linked from documentation will answer 400 when requested without its required parameters. That is the correct response to an incomplete request.
### How do I find broken in-page anchors?
List every link starting with # on the rendered page and check that an element with that id exists on the same page. A status-code crawl cannot find these, because the link fetches nothing.
### Should internal links point at redirects?
They work, but each costs an extra request. Update internal links to the final address and keep the redirect for visitors arriving from elsewhere.
### Why trace a broken link back to its template?
On a templated site, one defect in shared code usually breaks the same link on every page built from it. Fixing the template fixes all of them.
### Can Screaming Frog find broken anchor links?
Yes, if you switch it on. Enable Crawl Fragment Identifiers under Config > Spider > Advanced, crawl the site, then apply the Broken Bookmark filter on the URL tab. The setting is off by default, so a standard crawl does not report them.
## Related findings
[2026-09-01 Dead link or blind spot? When a checker's failure is its own Our sameAs check called a live profile page dead because our own fetch failed. It now reports such targets as unverifiable. How to](/blog/the-scanner-that-cannot-fetch-its-own-host)[2026-09-16 Canonical URL audit: do tags, redirects and sitemap agree? Audit canonicals from the responses, not the tag: apex and www both serving, half-done aliases, template canonicals and sitemaps t](/guides/canonical-url-audit-one-page-one-address)[2026-08-28 What is entity SEO? What machines must resolve to cite you Entity SEO in practice: the four failures validators never report, measured cases, the E-E-A-T evidence a machine can find, and ho](/guides/what-is-entity-seo)
The main product
Found this on your own site? **We fix it for $100.**
Scan free to see where you stand. The fix is one site, every finding implemented and re-measured, with a sealed before and after.
[Fix it all — $100](https://buy.stripe.com/4gM00j7OjawhdLEdyR2VG0v)
[What it covers](/pricing#fix) · [scan first, it is free](/)
Free browser extension
Check this on any site you open, **without leaving the page.**
CrawlCheck for Chrome grades every site you visit A–F and shows, from your own browser, what search engines and AI assistants are actually served. Free, no account.
[Add to Chrome — free](https://chromewebstore.google.com/detail/plgjengobhbdmbgckiiaaflgiepdbcpi?utm_source=crawlcheck.io&utm_medium=post)
[What it does](/extension)
Write for the library
Measured something yourself? **Submit an article.**
Send your finding, the data and the method. A person reads every submission and replies by email; accepted articles are published under your name with a link to your study. Free to submit, and placement is never for sale.
[Submit an article](/submit-finding#send)
[How it works](/submit-finding) · a reply within about a week
Spotted a mistake or a bug on this page? [Report an error](/report-error?k=article). Confirmed errors are fixed and logged on the [corrections page](/corrections).
## Comments
Comments are read before they appear. Nothing is published automatically, and no account is needed. An AI agent with a published signing key can post without the queue: it signs the request and the signature is checked against its own domain ([how](/api/comment/signed)). Those appear at once, marked *verified*.
Send for review
Writing about this? [Facts, live figures and marks](/press) — every number on that page is dated and traceable to a scan.
[All guides](/guides) · [The dataset](/data) · [How the dataset works](/policy)
We read your site the way crawlers do, from our own network, and every report says exactly what was asked for and what came back. [Who runs this](/author) · [Press kit](/press) · [Videos](/videos) · [Evidence for disputes](/evidence) · [YouTube](https://www.youtube.com/@crawlcheck) · [hello@crawlcheck.io](mailto:hello@crawlcheck.io)
#### How we measure, and our own machine files
CrawlCheck reads every site as itself. Its crawler-identity tests send named user-agent strings from its own network, never from an operator’s published range, so a site that verifies crawlers can tell them apart; every report says so. Results reflect what an anonymous non-browser client receives. [What we collect and how to opt out](/policy) · [how every report is timestamped](/proof) · [who runs this](/author) · [press kit](/press) · [hello@crawlcheck.io](mailto:hello@crawlcheck.io).
**Second vantage:** the [browser extension](/extension) measures what crawlers are served from your own address and after your JavaScript runs — the half this server cannot see. From 1.0.0 it sends the domain you ask about, on one button, and anonymous usage counts you can switch off; from 1.0.3 a footer switch turns off the hostname lookup for the grade on the icon; from 1.0.4 a share switch, on by default, sends a signed Two-vantages reading (status, size, words and a hash per identity; never page text); from 1.0.16 chips send the hostnames of search results and AI citations while the chips and grade switches are on; from 1.0.17 a readings switch, on by default, sends one signed page reading a day (numbers, the hostname and the page address with the query removed; never in incognito; at most 40 a day) and only while the share and grade switches are also on. Earlier versions send nothing.
**Our own machine layer for the agentic web:** [llms.txt](/llms.txt) · [llms-full.txt](/llms-full.txt) · [agents.md](/agents.md) · [SKILL.md](/SKILL.md) · [entitymap.json](/entitymap.json) · [media kit](/.well-known/media-kit.json) · [MCP server card](/.well-known/mcp/server-card.json) · [API catalog](/.well-known/api-catalog) · [OpenAPI](/openapi.json) · [robots.txt](/robots.txt) · [sitemap.xml](/sitemap.xml) · [security.txt](/.well-known/security.txt) — every one named in robots.txt, which is exactly what we check for on yours.
**Registry:** [LLMs](/registry/llms) · [Agents](/registry/agents) · [Entity](/registry/entity) · [AI policy](/registry/aipolicy) | **Pages:** [Removal](/removal) | **Glossary:** [AI classifiers: Shortlisting](/glossary/ai-classifiers/shortlisting) · [CAWG training and data mining assertion](/glossary/cawg-training-assertion) · [Sitemap URL set](/glossary/sitemap-url-set) · [Information architecture](/glossary/information-architecture) · [Tool permission](/glossary/tool-permission) · [Data sheet](/glossary/data-sheet) · [k-anonymity](/glossary/k-anonymity)
[Contact](/contact) · [Terms of service](/terms) · [Refund policy](/refunds) · [Trust center](/trust) · [Report an error](/report-error)
Same body as a file: /guides/how-to-audit-internal-links-and-dead-anchors.md · as data: /feed.json · what we tell agents site-wide: /llms.txt, /agents.md. To see any other site this way, from your own browser, after its JavaScript runs: the browser extension.