The difficult part of hreflang is not writing an XML tag. It is deciding which translations are real, keeping their URLs stable, and making every output agree about that decision.

Yangworks has a Chinese, English, and Japanese handbook built with small Node.js scripts. There is no runtime locale detector and no framework plugin. Each language receives its own URL; a source file decides whether that version is published; the same availability data drives the HTML, language switcher, canonicals, and sitemap.

That shared model matters more than the choice of XML versus head tags. Google treats HTML, HTTP-header, and sitemap hreflang as equivalent methods and says there is no Search benefit to maintaining several at once. Yangworks uses the sitemap because it is the easiest place for this build to generate complete reciprocal clusters.

Give every language a URL you can request directly

Chinese:  /handbook/              /handbook/niche
English:  /en/handbook/           /en/handbook/niche
Japanese: /ja/handbook/           /ja/handbook/niche

These are separate HTML documents. The server does not return different languages from one URL based on a cookie, IP address, or Accept-Language header. A reader can copy an English URL and know that it will still be English when someone else opens it.

Google’s current guidance recommends separate URLs for language versions and lists country domains, subdomains, and subdirectories as workable structures. The choice of /en/ here is operational, not a claim that subdirectories receive a ranking bonus. One host and one build are simpler for this site. A project with different regional ownership or infrastructure might choose subdomains or country domains for good reasons.

Do not move the default language just to make the tree pretty

Chinese already lived at /handbook/... when English and Japanese were added. Moving it to /zh/handbook/... would have created a second migration: redirects, rewritten links, changed canonicals, and a period in which old and new URLs competed for attention.

The build therefore keeps an asymmetric but stable mapping:

LANGS = ['zh', 'en', 'ja']
DEFAULT_LANG = 'zh'

zh -> /handbook/...
en -> /en/handbook/...
ja -> /ja/handbook/...

The prefixes do not need to be symmetrical. What matters here is keeping one preferred URL for each Chinese page instead of publishing both /handbook/niche and /zh/handbook/niche.

Publication starts with the file, not the language registry

The page registry knows the shared slug, source filename, order, and translated navigation labels. It does not assume every translation is ready. Availability comes from the filesystem:

const contentPath = (lang, file) =>
  path.join(ROOT, 'content', lang, file);

const hasLang = (page, lang) =>
  fs.existsSync(contentPath(lang, page.file));

const availableLangs = (page) =>
  LANGS.filter((lang) => hasLang(page, lang));

If content/ja/04-validation.html is missing, the build creates no Japanese validation page. The Japanese navigation omits it. The language selector on the Chinese and English versions does not offer Japanese. The sitemap does not include the URL or advertise it as an alternate.

The rule prevents the build from emitting hreflang for a translation it did not render. It says nothing about quality: a copied, unfinished, or awkward file still counts as published, so editorial review must happen before the file enters the language directory.

Canonicalize each translation to itself

The three versions of the same handbook chapter use three canonicals:

https://yangworks.dev/handbook/niche
https://yangworks.dev/en/handbook/niche
https://yangworks.dev/ja/handbook/niche

Each page uses its own language URL as canonical. Pointing the English and Japanese pages at Chinese would introduce a conflicting consolidation signal where the build intends three independently indexable translations.

Google recommends a self-referencing canonical on the canonical page and, when canonical and hreflang are combined, a canonical in the same language where possible. The Open Graph URL uses the same clean URL for consistency.

Canonical and hreflang are related but not interchangeable. Canonical chooses the preferred URL for a document. hreflang describes equivalent pages intended for different language or regional audiences.

Build one reciprocal cluster, then reuse it

For each handbook page, the root build finds the languages that exist and constructs one alternate list. When all three translations are present, the Yangworks list contains Chinese, English, Japanese, plus its chosen x-default fallback. x-default is useful here but is not required to form a valid hreflang cluster. The same list is written into the sitemap entry for every available version:

<url>
  <loc>https://yangworks.dev/en/handbook/niche</loc>
  <xhtml:link rel="alternate" hreflang="zh-Hans"
    href="https://yangworks.dev/handbook/niche"/>
  <xhtml:link rel="alternate" hreflang="en"
    href="https://yangworks.dev/en/handbook/niche"/>
  <xhtml:link rel="alternate" hreflang="ja"
    href="https://yangworks.dev/ja/handbook/niche"/>
  <xhtml:link rel="alternate" hreflang="x-default"
    href="https://yangworks.dev/handbook/niche"/>
</url>

Every sitemap entry repeats the same alternate set and includes its own URL, so the relationships are reciprocal by construction. Google may ignore a one-way relationship; one generated list is less error-prone than three hand-maintained copies.

The language codes are zh-Hans, en, and ja. The sitemap declares the XHTML namespace used by xhtml:link, uses absolute HTTPS URLs, and is advertised in robots.txt.

x-default is a fallback, not a boost

Google describes x-default as the URL for users whose language or region does not match another entry. It can point to a language selector, a neutral page, or a chosen default version. Google has also said it may help URL discovery. None of that makes it a ranking shortcut.

Yangworks points x-default to the established Chinese version of each page. That does not make Chinese canonical for the English and Japanese pages, and it does not redirect a visitor.

There is one implementation debt here. The generator assumes the Chinese source exists whenever another language exists. That is true today, but it is not enforced. An English-only future page could produce an x-default URL that was never built. The build should eventually fail that state or choose an available fallback explicitly.

Keep the language switcher even when the sitemap is correct

Every page with more than one published version shows a visible language selector. The current language is text with aria-current="true"; the others are ordinary anchors to the clean localized URLs.

These links are not redundant. Google recommends visible links that let users change language, and the ordinary anchors give crawlers a path between versions. They also work without JavaScript.

The site does not redirect by IP address or Accept-Language. Google warns that inferred-language routing can hide versions from users and crawlers. Googlebot usually originates from US IP addresses and sends requests without an Accept-Language header, so a single adaptive URL is a poor discovery mechanism.

The document lang attribute is still set to zh-Hans, en, or ja for semantics and assistive technology. It is not a substitute for clearly translated visible content; Google says visible content is how it determines page language.

Language roots and missing routes should tell the truth

The content roots are /en/handbook/ and /ja/handbook/. The build also writes public/en/index.html and public/ja/index.html. Workers serves those at /en/ and /ja/, while the no-slash forms may first receive the default trailing-slash redirect.

Each generated language-root page contains a canonical to the handbook root, a zero-second meta refresh, a visible link, and noindex. It is an HTML forwarding page, not an HTTP 301. That distinction is visible in the response headers and should stay visible in the documentation.

Unknown handbook paths use Workers Static Assets with not_found_handling: "404-page". The shared error page is marked noindex and links to the three handbook roots. A browser showing the right design is not enough; the response must still carry a 404 status.

The current internal links are not fully clean

Canonicals, sitemap locations, alternates, and the language selector use extensionless leaf URLs such as /en/handbook/niche. Handbook index URLs keep their trailing slash.

Some generated navigation and hand-authored cross-links still use ./niche.html. Workers redirects them to the clean public form, so they work at the cost of an extra hop. Replacing those links is worthwhile, but it needs to be done across the generators and all three language trees rather than as a search-and-replace in one template.

The file-exists rule also cannot validate links inside an HTML fragment. A translated article can still link to a sibling translation that is absent. Complete current coverage hides that problem; future partial launches need an internal-link check in the build.

Verify relationships, not just counts

The handbook currently has 13 pages in three languages, so the build emits 39 handbook URLs into the sitemap and 39 self-referencing canonicals in the handbook output. The sitemap total is larger, because root-level and publication pages are appended to the same file. Counts like these are useful smoke tests, not proof that the clusters are correct.

The stronger check walks the generated output and asks:

  • Does every sitemap location resolve to a generated page?
  • Does every localized entry list itself and every available alternate?
  • Are the return links identical across the cluster?
  • Does each page canonicalize inside its own language tree?
  • Do language links omit versions that were not built?
  • Does x-default resolve to a real fallback?
  • Do missing routes return a real 404 and a noindex page?
  • Do the .html links reach the advertised clean URL without loops?

A framework would not remove these questions; it would only move the code that answers them. The durable part of the Yangworks implementation is that page availability, URL generation, canonicals, language links, and sitemap alternates all start from the same small set of facts.