Skip to content
SEO7 min read

Making hreflang tell the truth

Two defects from the previous version of this site: one canonical in the root layout covering thirty pages, and hreflang promising an English version of a Russian article. Both are fixed by a type, not by a checklist.

Audits of bilingual sites check that hreflang tags exist and that they are reciprocal. That is worth checking, and it is not where bilingual sites break. They break where the tags are present, reciprocal, well-formed — and false. Here are two defects from the previous version of this site. Both would have passed any automated check.

Defect one: one canonical for the whole site

Metadata is inherited in the App Router. The metadata object exported from app/layout.tsx applies to every page that does not override it. Put alternates.canonical in there and you get a site where roughly thirty pages nominate the home page as their canonical version.

This is not a soft signal that a search engine will weigh against other evidence. It is an instruction: the page is saying it is a duplicate and asking for a different URL to be indexed in its place. What makes it dangerous is that no checklist can see it. The tag is present, the syntax is valid, the value is absolute. The bug is that the value is the same on every page, and a per-page audit never looks at two pages at once.

So the canonical is computed per page, and the function that computes it takes two arguments:

export function alternatesFor(
  path: string,
  locale: Locale,
  { available = LOCALES }: { available?: readonly Locale[] } = {},
): { canonical: string; languages: Record<string, string> }

The second argument is the locale of the page being rendered, and it is not there for symmetry. Without it the canonical would be derived from the locale-independent path alone, and every English page would canonicalise to its Russian twin. Same defect, smaller blast radius, considerably harder to spot: the English tree quietly leaves the index while the Russian tree stays, and nothing looks broken from the inside.

Defect two: hreflang pointing at a translation that does not exist

The old site had an /en/blog. It served Russian article bodies under inLanguage: "en-US", because the file holding the English texts was a byte-for-byte copy of the Russian one. The hreflang="en" tag said: here is the English version. A reader clicked the language switch and got Russian.

That is not a typo somebody forgot to finish. It is structural. If the type demands a body in both languages and only one exists, the cheapest way to satisfy the compiler is to copy. A type that makes the dishonest option the only expressible one will get the dishonest option, reliably, forever.

Hence the one asymmetry in lib/content/types.ts:

title:   Localized<string>;                        // total: both languages required
excerpt: Localized<string>;                        // total: both languages required
body:    Partial<Record<"ru" | "en", ArticleHtml>>; // partial, on purpose

A title and a summary are two strings. They can always be translated honestly, and the English index needs them anywhere it cross-links. A full body is a day of work. Making body total would not have produced translations; it would have produced pressure to machine-translate one for the sake of a green build. Partial is not laxity here. It is permission to state a fact: this article is published in Russian and is not published in English.

One place that knows which languages a document exists in

The next requirement is that everyone who needs that fact reads it the same way. In this file that is exactly two functions:

export function entriesForLocale(locale: Locale): readonly JournalEntry[] {
  return JOURNAL.filter((entry) => hasBody(entry, locale));
}

export function localesForEntry(entry: JournalEntry): readonly Locale[] {
  return LOCALES.filter((locale) => hasBody(entry, locale));
}

The first answers "what is published in this language" and is read by the journal index, by sitemap.xml and by the RSS feed. The second answers "which languages may be listed in alternates" and feeds the available option of alternatesFor(). The article you are reading is an element of the array those two functions filter.

The point is not brevity. The point is that four surfaces now cannot disagree: a page cannot enter the sitemap in a language the index does not list it in, and hreflang cannot reference a URL that is absent from the sitemap. Reciprocity becomes a property of the data rather than something a human verified once.

hasBody checks for a non-empty string, not merely for the presence of the key. body: { en: "" } type-checks and publishes a blank page into the sitemap; a one-line predicate is cheaper than working out later where the empty document came from.

x-default, and why it is conditional

x-default points at the primary language, which here is Russian — but only when the Russian version actually exists. That looks like paranoia right up to the first page that exists only in English, at which point an unconditional x-default is sending the rest of the world to a 404.

Where the scheme is sharp

Russian lives at the root and English under /en; there is no [locale] segment. The upside is obvious: /pricing rather than /ru/pricing, and no redirect on the most common entry point. The downside deserves naming too. The route tree is no longer symmetric, and every helper has to remember that one of the two languages has no prefix. Which is precisely why all of them live in a single module: a second place that decides "is this English?" is the mechanism by which the answers start to differ.

The cheapest way to get this wrong looks like this:

// startsWith("/en") classifies /enterprise as an English page
pathname === "/en" || pathname.startsWith("/en/") ? "en" : "ru"

The boundary check is explicit. Without it, any path beginning with those two letters gets an English <html lang>, an English canonical, and a language cookie pinned on the way past.

One last piece: proxy.ts performs no geo-IP redirect. Two reasons, both worth having. The first is cost — an Edge invocation on every cold entry and an uncacheable home page. The second is hreflang honesty. Googlebot crawls from US addresses; a country redirect shows it the English page at the Russian URL, which breaks exactly the URL-to-language correspondence hreflang is supposed to describe. Language here is an explicit human choice, and proxy.ts does nothing but remember it.

The takeaway

A tool can tell you hreflang is present. It cannot tell you the page at the other end is in the wrong language, and it cannot tell you that thirty pages have declared themselves duplicates of one. Both facts are visible only in the code that emits the tags — which is the argument for there being exactly one such place rather than one per page template.