The 350 links pointing to nowhere — the day hreflang became noise
Discoveries·

The 350 links pointing to nowhere — the day hreflang became noise

First: what hreflang actually is

Picture a book that exists in two languages. At the start of each chapter, a note says: “this same chapter, in English, is on page X”.

hreflang is that note inside the page’s HTML. One line in the head:

<link rel="alternate" hreflang="pt" href="/post/exemplo/" />
<link rel="alternate" hreflang="en" href="/en/post/example/" />
<link rel="alternate" hreflang="x-default" href="/post/exemplo/" />

It tells Google: “this page also exists in another language, at that other address”. The tag doesn’t translate anything and redirects no one. It’s a pure declaration — a hint that helps the search engine pick which version to show whom. Someone searching in English gets the English link, someone searching in Portuguese gets the Portuguese one. Without the hint, Google serves whichever version it guesses, and the visitor lands in the wrong language.

x-default is the third option: “if the person speaks neither Portuguese nor English, send them here” — in our case the Portuguese version, which is the default.

The one rule that matters

For the hint to work, the other side has to confirm it.

If the Portuguese page declares “my English sibling lives at /en/post/example/”, then the page at /en/post/example/ must declare back “my Portuguese sibling lives at /post/exemplo/”. That’s called reciprocity, and Google’s official docs are blunt: a pair without a return path, both tags get ignored.

Notice what that means in practice: a broken hreflang doesn’t show up in the browser console, doesn’t turn the build red, doesn’t trigger any alert of any kind. The only place the problem exists is on Google’s side — where nobody is looking. A site can spend years “declaring” translated versions that the search engine throws away silently.

That was exactly our case.

What was happening here

The blog generates the language tags automatically for every page — each of the site’s thousand-plus pages declares the Portuguese version, the English one, and the default. The audit counted 350 links in that shape: 169 going Portuguese to English, 166 the other way, 15 x-default.

A good share of them pointed at an address that never existed. Concrete example: the post 2026-07-28-semana-da-qualidade-portfolio has an English file whose name is translated — 2026-07-28-quality-week-portfolio. But the tag generator didn’t know that. It built the English address by rule: take the Portuguese path, paste /en in front. Result: a link to /en/2026-07-28-semana-da-qualidade-portfolio/ — a page that exists nowhere. Google reads it, finds no other side, discards the pair. Hundreds of times.

The rule generating those links was written before the blog was genuinely bilingual. Back then there were two or three fixed pages, and “paste /en in front” solved it. The blog grew, moved to one file per language, and the assumption stayed, enforced like a law nobody ever voted on. And reality has three folds the rule didn’t foresee:

Translated filename. Some posts were born with the same slug in both languages; others came with the title translated in the English filename. Concatenation has no way to guess which case it’s in.

A page that exists in only one language. The hidden-draft review panel is pure Portuguese — there is no English version (nor any need for one). Emitting an alternate from there is inventing a page.

Route aliases. The fixed pages don’t mirror their names: /arquivo lives at /en/archive, /sobre at /en/about. Pasting the prefix yields /en/arquivo, which is a 404.

How the pairing actually works

Here’s the point almost everyone (me included) gets wrong: the two languages don’t recognize each other by address.

Each post carries, in its frontmatter, a publication date and a project. The system that matches languages uses that pair — same pubDate, same project. If the Portuguese file and the English file match on both fields, they’re siblings, no matter what each one is named on disk.

The cure was bringing that truth into the tag generator. Instead of the layout (which only sees the page’s path) guessing the sibling’s address, the one that knows who the sibling is turns out to be the page that loads the post collection — it queries the date + project pairing and hands the layout the right path, ready-made. The layout stopped deriving and started receiving.

Two details surfaced along the way.

First: the prop carrying the sibling needs three values, not two. “This page has a sibling, the path is this” (a string). “This page has no sibling, emit no alternate” (null). And “nobody told me anything” (absent — falls back to the old rule, which works, for the fixed pages). The original bug lived precisely in treating the last two as the same thing: without separating “no sibling” from “not provided”, the site keeps inventing links.

Second: branch order. Hidden-post previews have their own path shape (/ocultos/preview/pt/...). In the first version of the fix, the general case ran before the preview case and built an English URL out of an already-composed Portuguese one. About 37 broken links survived — all in the review panel, exactly the part of the site that never shows up in the menu. Reordering the cases closed it, and the rule stuck: a case that doesn’t appear in navigation still appears in the build.

Two finds that weren’t hreflang

The same sweep ran into two more things.

The site’s manifest (the file that tells a phone how to install the blog as an app) asked for two icons, and the iPhone asked for a third. All three paths had 404’d since day one. The reason: the .gitignore had a global *.png line, placed there so test screenshots would never get committed. The side effect: it was impossible to commit the icons without forcing it — and nobody ever forced. The files simply never existed in the repository. Fixed with an exception for the folder (!public/icons/*.png) and two properly drawn icons (192×192 and 512×512).

And the link watchdog, once written, failed its own test: the regression spec navigated to a route that never existed in the project and treated the 404 as a site defect. The watchdog accused the innocent, and the guilty one was the watchdog. Cut down to an honest smoke test, with the exhaustive coverage left to the script that walks the build and checks, one by one, every declared alternate against every generated file.

The two checks that stayed

So the problem can’t come back quietly, two checks now run in the pipeline:

  1. PT/EN sync — compares each Portuguese post against the English ones by the date + project pair. A new post without a translation fails the build with an explicit warning, before any tag is emitted.
  2. Alternate validator — runs after the build, reads every generated HTML, extracts every link rel=alternate, and confirms the destination exists. Any invented path becomes exit 1 and the deploy stops. A hundred and fourteen lines, zero leniency.

A technical detail that almost slipped: comparing a filesystem path against an HTML link requires decoding the percent-escapes (%C3%AD becomes the matching accented letter) and normalizing accents — the same “í” can exist in two byte shapes. Without that, the validator flagged perfectly valid links as broken. And a false positive teaches you to disable the guardian faster than any real bug would.

Metrics

Item Before After
Alternate links pointing to 404 350 0
Alternates declared in the build ~3.5k 3,876
Manifest icons present in the bundle 0 of 2 2 of 2
Error page indexable yes no
Guardian running in CI didn’t exist post-build, blocking
Proof build – 1,293 pages, unit tests 89 of 89

Lessons

The explanation comes before the cure. Understanding what the tag does on Google’s side — asking for a confirmation back — is what showed why our path was wrong. Every conversation with an external system (search, email, payments) starts better with “how does this work over there” than with “why is my code broken”.

Silent invariants are the most expensive. “Every Portuguese page has English at the same path” was never decided out loud. An assumption nobody wrote down has no owner, and it’s the one that becomes three lines of concatenation in a layout.

A declaration without verification is decoration. hreflang is a request, not an order. The real verifier lives outside, at Google. Until someone confirmed the return path, a green build meant nothing.

Absence is a value. “No sibling” and “not provided” had to be separated for the system to stop inventing. Half of the integration bugs I fix are two different intentions sharing one state.

The validator needs tests too. I wrote the guardian and the spec that failed through its own fault on the same day. Trusting a tool without reading its accusation is just outsourcing the bug.

What comes next

What stays open is the other side of the coin: a new post can be born with broken hreflang in a way that neither the derivation nor the divergent name covers — a translation published with a different date than the original. The pairing matches on the exact pair; if the date slips between the two files, the sibling vanishes with no warning. The next check that makes sense to write isn’t “does this link exist” — it’s “does this post have a sibling”.

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$