A link audit that comes back clean is easy to trust and easy to misread. It does not say the site has no broken internal links. It says the links the checker knew how to find all resolve. Those are very different claims, and the distance between them is where a real problem lived on one of our own product sites for months: Google Search Console listed 160 “Not found (404)” URLs while our own link audit of the same build reported zero.
Both were accurate. The audit matched <a href>. The links that were broken were not anchors.
This post is about that class of bug — verification that passes because it is looking at the wrong thing — and about the specific shape it takes on multilingual sites, which is where it does the most damage. It is a companion to our guides on testing AI-generated code and QA and risk governance for AI output: those are about not trusting code you did not write, this is about not trusting a check you did write.
1. The URL that could not exist
The thing that made the Search Console export worth reading rather than dismissing was a single URL:
/zh/blog/pidkliuchena-polova-dokumentatsiia/
A Chinese locale prefix in front of a Ukrainian slug. No such page had ever been built. No such page could have been built — the generator derives the slug from the translation, so a Chinese URL gets a Chinese slug. Nobody had typed this. No external site had linked to it.
That is the useful kind of anomaly. A 404 on a deleted post is ordinary housekeeping. A 404 on a URL that your build is structurally incapable of producing means something in your own output invented it, and invention at that scale is never a one-off.
Dozens of the 160 had the same signature: a real slug from one language, sitting behind another language’s prefix.
2. Two bugs, one shape
Tracing it back produced two separate defects, written months apart, in different files, by different people. They were the same mistake:
Build a link as “the current path, under a different locale prefix,” without checking that the other locale has that page.
The first was in hreflang annotation. The layout emitted alternates for all nine locales using one path, so a post that existed only in Spanish was still announced at /zh/blog/<slug>/, /de/blog/<slug>/ and six others. Googlebot treats hreflang as a discovery mechanism — it followed them, found nothing, and recorded 404s.
The second was in the language switcher, and it survived the fix for the first one because it was invisible to the audit that verified the fix.
3. Why the switcher was invisible
The switcher is not a list of links. It is a form control:
<select id="locale">
<option value="/blog/5/" data-locale="en">English</option>
<option value="/de/blog/5/" data-locale="de">Deutsch</option>
<option value="/zh/blog/5/" data-locale="zh">中文</option>
</select>
A crawler that extracts href attributes from <a> elements sees none of these. It reports success on a page whose language menu offers three destinations, two of which return 404.
That is what happened. The audit walked every page in the build, matched <a href>, checked every target against the output directory, and reported no broken links — correctly, for the question it was actually asking.
The real number, once we looked at <option value> as well, was 36 dead links across 12 distinct targets, every one of them generated by our own build.
4. Uneven translation coverage is the trigger
“Current path under another prefix” is not obviously wrong. It is correct, right up until translation coverage stops being uniform. Ours was not:
| Locale | Posts |
|---|---|
| Spanish | 79 |
| English | 74 |
| Italian, Japanese | 69 |
| Ukrainian | 66 |
| French, Portuguese | 65 |
| German | 43 |
| Chinese | 41 |
Blog pagination turns that table into dead URLs mechanically. English had five pages of posts; German and Chinese, with roughly half the content, had two. So /blog/5/ offered /de/blog/5/ and /zh/blog/5/ — pages that will not exist until those locales roughly double their content.
Spanish made it worse in the other direction. As the fullest locale it had a sixth page, and /es/blog/6/ offered a sixth page to all eight other locales, none of which had one.
This is worth stating plainly because it is the part teams get wrong when they triage: the bug is not the missing translations. The missing translations are a content decision. The bug is a navigation control that offers destinations without checking they exist, which converts an ordinary content gap into broken URLs automatically, and generates more of them every time the gap widens.
Whatever your equivalent is — locale prefixes, tenant slugs, versioned docs, region-specific catalogues — the same reasoning applies. A link built by string substitution across a dimension with uneven coverage is a 404 generator with a delay fuse.
5. What it actually costs
Not a ranking penalty. Google is explicit that 404s are a normal part of a site’s life, and if you delete a page the honest answer is a 404. It is worth being clear about that, because “we have 404s” triggers a lot of unnecessary work.
The costs are subtler and mostly operational:
- You cannot separate signal from noise. When 160 entries in a coverage report were generated by your own templates, you have no cheap way to spot the twenty that represent something real — a page that moved, a URL with inbound links, a route that regressed.
- Crawl budget goes to nothing. Every invented URL is a fetch that could have gone to a page you wanted indexed. On the same property, 161 URLs sat in “Discovered — currently not indexed” while the crawler worked through invented ones.
- A broken control is a broken control. Set search aside: a user on page 5 of the blog who picks German from the menu lands on an error page. That is a UX defect, and it would be a defect if no crawler existed.
- The number only grows. A template that emits bad URLs keeps emitting them. Each new post in the fullest locale can add another page that every other locale is invited to visit.
For scale: Ahrefs studied hreflang across 374,756 domains and found that 67% of implementations had issues. This is not an exotic failure. It is close to the default outcome.
6. The fix is structural, not corrective
The tempting fix is a redirect map: catch the dead URLs, send them somewhere sensible, move on. We tried the front half of that and it is the wrong instinct. A redirect table is a list of mistakes you have agreed to keep making, it needs an entry for every new one, and pointing a deleted page at a section index tends to be read as a soft 404 anyway.
The fix that holds is to make the generator incapable of the mistake.
In our case the page already computed the correct answer. It built a clamped map of “which locales actually have this page” for hreflang, and it simply was not handing that map to the navigation component, which therefore fell back to path substitution. Passing the map through was a one-line change:
- Locales that have the page get a link to it.
- Locales that do not are not offered a page that does not exist; they fall back to the section root in their own language.
Both properties come from the same source of truth, so they cannot drift apart again. The general rule: a link generator should take the set of valid destinations as an input, not reconstruct it by string manipulation. If the caller cannot supply that set, the component should degrade to something that always exists rather than guessing.
7. Enumerate your link carriers, then check the build
The durable lesson is not about locales. It is that a link checker protects you only from the carriers it knows how to look for, and most tools know exactly one.
We replaced the audit with a script that runs after the build and checks four:
const CARRIERS = [
{ label: "a[href]", pattern: /<a\b[^>]*?\bhref="([^"]+)"/gi },
{ label: "option[value]", pattern: /<option\b[^>]*?\bvalue="([^"]+)"/gi },
{
label: "link[hreflang]",
pattern: /<link\b[^>]*?\bhreflang="[^"]*"[^>]*?\bhref="([^"]+)"/gi,
},
{
label: "link[canonical]",
pattern: /<link\b[^>]*?\brel="canonical"[^>]*?\bhref="([^"]+)"/gi,
},
];
Three properties matter more than the parsing:
It runs against the build output, not the live site. Every internal target either exists in the output directory or it does not. No network, no flaky hosts, deterministic in CI — and, critically, it fails before deploy rather than reporting after Google has already indexed the damage. On our build that is 45,738 internal links across 748 pages, checked in seconds. For external URLs, reach for a dedicated tool like lychee or linkinator; mixing the two concerns makes the fast check slow and flaky.
The carrier list is the thing you maintain. The regexes are trivial. The judgement is in knowing which elements in your codebase can produce a URL. Ours grew by one the day we found the switcher, and it will grow again — a data-href on a card wrapper, a JSON island feeding client-side routing, a redirect map. Every entry you have not added is a blind spot that reports green.
Test the checker against a known failure. A checker nobody has watched fail is an assertion nobody has verified. We reverted the switcher fix, rebuilt, and confirmed it reported exactly the 12 targets and 36 links we expected, then restored the fix and confirmed it went quiet. That took ten minutes and is the only reason we believe the green run.
8. The same failure mode on mobile
Nothing here is web-specific. The pattern is: a value is constructed by substitution across a dimension the constructor does not fully know about, and the test suite reads a different representation than the one that ships. Mobile has the same shape in several places.
- Deep links and universal links. Route templates are built by string interpolation, and the test asserts the string. Whether the target route is registered in the current build is a different question, usually answered in production by a user landing on a blank screen.
- Navigation graphs. A route table that enumerates destinations statically will happily contain routes no screen claims, and screens that no route reaches. Nothing fails at compile time. The same “does the destination actually exist” check applies, and it is cheap to write once you have the graph.
- Localized resources. Same trigger as the web case: a key present in the base language and absent in a translation. If your lookup falls back silently you ship untranslated strings; if it throws you ship crashes in exactly the locales you tested least.
- Feature flags and remote config. A key read in code and never defined in the dashboard is the same defect with a different transport.
In each case the useful move is the same one: check the artifact you actually ship, against the set of things that actually exist, in CI. We make that argument at more length for generated code in How to Test AI-Generated Flutter Code and for pre-release readiness in Hardening a Flutter MVP for Production.
9. What to take away
- A clean link report answers a narrower question than it appears to. Find out which one before you trust it.
- Write down every element in your codebase that can emit a URL. That list, not the crawler, is your actual coverage.
- Never build a cross-dimension link by substitution. Pass in the set of valid destinations.
- Check the build artifact in CI, not the deployed site. Failing before deploy is the whole point.
- Make your checker fail on purpose once. Otherwise its green run is an untested assertion.
- Do not confuse a content gap with a defect. Uneven translation coverage is a decision. A control that offers pages which do not exist is a bug.
FAQ
Why does my link checker report no broken links while Search Console reports 404s?
Most likely your checker and your site disagree about what counts as a link. Crawlers read anchors, and often little else. If a navigation control is built from a select element, a data attribute, a JSON island consumed by client-side routing, or a redirect map, the URLs it emits never enter the crawler’s queue — but Googlebot still reaches them through hreflang annotations, sitemaps, or by executing the page. The first thing to check is not the crawler’s configuration but the list of elements in your codebase that can produce a URL.
What is the most common cause of broken internal links on multilingual sites?
Building a link as the current path under a different locale prefix without checking that the other locale has that page. It is correct as long as every page exists in every language, and it silently turns into a 404 factory the moment translation coverage becomes uneven. Ahrefs found issues in 67% of hreflang implementations across 374,756 domains, and this pattern is a large share of them.
Should a link check run against the live site or the build output?
The build output, in CI, before deploy. Checking the live site tells you what search engines have already been able to crawl, which means you learn about the problem after it has been indexed. Checking the build artifact is also fully offline and deterministic: every internal target either exists in the output directory or it does not, with no network calls and no flaky external hosts.
Is a missing translation the same problem as a broken link?
No, and conflating them makes both harder to fix. A missing translation is a content gap you may be fine with. A broken link is a navigation control offering a destination that does not exist. The fix is to make the link generator structurally incapable of offering an absent page — it should list only the locales that actually have the page and fall back for the rest — so that uneven translation coverage stops producing dead URLs on its own.
We build and modernize cross-platform mobile apps, and we run our own products, which is where problems like this one surface before a client ever sees them. If you want a second pair of eyes on where your release process is asserting more than it verifies, book a 20-minute call.