Baobaobaolin.com
date
entry
016
topic
tooling
rev
1

A bilingual static site: canonical points at itself, hreflang states the rest

This site exists in Chinese and English. The easiest mistake when setting that up is pointing the English page's canonical at the Chinese one — a sentence that means "the English page need not be indexed", after which it genuinely stops appearing in results.

The two tags answer different questions:

  • canonical — what is this page's official URL? (for one piece of content reachable at several addresses: tracking parameters, casing, trailing slashes)
  • hreflang — which other language versions of this content exist?

Hand the second question to canonical and you have said "the English and Chinese pages are two URLs for one page; please index only the Chinese one". Search engines will comply.

The correct arrangement

The Chinese page's <head>:

<link rel="canonical"  href="https://example.com/posts/foo/" />
<link rel="alternate" hreflang="zh-Hant"   href="https://example.com/posts/foo/" />
<link rel="alternate" hreflang="en"        href="https://example.com/en/posts/foo/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/posts/foo/" />

The English page — note that canonical changes and the three hreflang lines do not:

<link rel="canonical"  href="https://example.com/en/posts/foo/" />
<link rel="alternate" hreflang="zh-Hant"   href="https://example.com/posts/foo/" />
<link rel="alternate" hreflang="en"        href="https://example.com/en/posts/foo/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/posts/foo/" />

Three points:

  1. Canonical always self-references. Each page points at itself and both language versions get indexed independently
  2. The hreflang set includes itself. The Chinese page lists zh-Hant pointing at itself, not only the other language
  3. x-default is where unmatched languages land. Mine points at Chinese, because most readers are in Taiwan

Why it has to be reciprocal

Hreflang is a claim, and a one-sided claim is not believed. The Chinese page says "my English version is over there"; if the English page does not point back, the pairing is disregarded — anyone could otherwise declare themselves a translation of someone else's page.

For a hand-maintained static site that is a real problem: publishing an article means edits spread across two HTML files, and updating only one produces no error at all. It just quietly stops working — the same failure mode as schema drift, where nothing exists to tell you it broke.

One more detail people miss: <html lang> has to change too.

<html lang="zh-Hant">   <!-- Chinese -->
<html lang="en">        <!-- English -->

That attribute is not for search engines. It is for screen readers and browser translation. Set it wrong and the page is read aloud with the wrong pronunciation rules from top to bottom.

Feeds come in pairs

An RSS channel has a <language> element, and it describes the whole feed, not individual items. Mix both languages into one feed and that field is necessarily wrong, whichever value you choose.

Two feeds, each complete:

<!-- /feed.xml -->
<language>zh-tw</language>
<atom:link href="https://example.com/feed.xml" rel="self" />

<!-- /en/feed.xml -->
<language>en</language>
<atom:link href="https://example.com/en/feed.xml" rel="self" />

Each language's pages should advertise their own feed in <head>, rather than both pointing at one. A reader who chose the English site should receive only English.

Sitemaps use xhtml:link to group them

Each language version is its own <url> entry, and every entry lists the whole group with xhtml:link — the same content as those three hreflang lines in the HTML:

<url>
  <loc>https://example.com/posts/foo/</loc>
  <xhtml:link rel="alternate" hreflang="zh-Hant"   href="https://example.com/posts/foo/"/>
  <xhtml:link rel="alternate" hreflang="en"        href="https://example.com/en/posts/foo/"/>
  <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/posts/foo/"/>
  <lastmod>2026-09-06</lastmod>
</url>

Remember to declare xmlns:xhtml on <urlset>; without it the whole sitemap fails to parse — and the way that failure presents is a search engine quietly ignoring it while the file looks fine in your browser.

Hand-maintained means it needs a check

All of the above means publishing one article touches six places: two HTML files, two listing pages and two feeds. Miss one of the six and the site is inconsistent, with nobody to tell you.

Worth running a check after each build, roughly:

  • does every page's canonical point at its own URL
  • does every page carry three hreflang lines, identical between the two language versions
  • do both feeds have as many items as there are article directories
  • does every internal link resolve to a file that exists

All of that fits in a few dozen lines of script. Same principle as the /version piece: let a machine decide whether things are consistent, instead of a person remembering.

While we are here: lastmod. Publishing an article also changes the home page (the listing gained an entry), so the home page's lastmod moves too. It is the most frequently forgotten field, because the person editing is thinking "I only added an article".

Do not fill the second language with machine translation

One last thing, less technical and more important: hreflang only declares that two pages are language versions of each other. It does not make either of them worth reading.

My two versions are written separately rather than translated, because the same point made to readers in a different language wants different examples and a different amount of assumed background. If you do not intend to write the second language properly, shipping one language is the better choice: one good version beats a good one plus a filler one.

If you remember one thing

canonical says who I am; hreflang says what my other selves are. The first always self-references; the second is symmetric and includes itself. Those two sentences cover ninety percent of bilingual site misconfiguration.

Revision history

  1. Sitemap generation automated; sync points reduced from seven to six
  2. First published