DEV Community

Peter Panda
Peter Panda

Posted on

Building a Locale Switcher That Respects hreflang and User Choice

Language switchers look trivial: a dropdown with flags. In practice they are where many multilingual sites break their own SEO and annoy users at the same time. This post outlines a switcher design that keeps search engines and humans happy, with small code samples.

Principles

  1. Every language version has its own URL (/en/, /de/, /ja/).
  2. The switcher links to the equivalent page, not the homepage of the other language.
  3. Automatic detection suggests, it never forces a redirect.
  4. A user's explicit choice is remembered.

Generate links from the same map as hreflang

Keep one data structure that lists equivalents for each page, and use it both for <link rel="alternate" hreflang> tags and for the switcher. If they come from different sources, they will drift.

// alternates is injected server-side: { en: "/en/pricing", de: "/de/preise", ja: "/ja/pricing" }
function renderSwitcher(alternates, current) {
  return Object.entries(alternates).map(([lang, href]) =>
    `<a href="${href}" hreflang="${lang}" lang="${lang}"${lang === current ? ' aria-current="true"' : ''}>${LABELS[lang]}</a>`
  ).join("");
}
Enter fullscreen mode Exit fullscreen mode

Use real anchor tags with href. Switchers built from buttons and JavaScript click handlers are invisible to crawlers.

Labels: language names, not flags

Flags represent countries, not languages. Spanish speakers in Mexico do not want a Spanish flag. Show each language in its own script: English, Deutsch, 日本語, Español.

Detection as a banner, not a redirect

If the browser language differs from the page language, show a small banner: "This page is available in Deutsch. Switch?" Redirecting automatically based on Accept-Language or IP can prevent crawlers from seeing other versions and traps travellers.

const pref = localStorage.getItem("lang");
const browser = navigator.language.slice(0, 2);
if (!pref && browser !== document.documentElement.lang && alternates[browser]) {
  showBanner(browser, alternates[browser]);
}
Enter fullscreen mode Exit fullscreen mode

Remember the choice

When a user clicks a language, store it in localStorage or a cookie. On future visits, still serve the URL they asked for; just use the stored preference to decide whether to show the banner.

Missing translations

If a page has no equivalent in a language, either hide that option or link to the closest parent page with a note. Never link to a 404, and never list it in hreflang.

Test it

Crawl the site and check that every hreflang target returns 200, lists the source page back, and appears in the switcher. A few lines of Python with requests and a regex is enough for small sites.

More detail on URL structures and common hreflang errors is in our hreflang implementation guide.

Published by PandaWM, an overseas marketing team based in Hong Kong.

Top comments (1)

Collapse
 
mercerk17 profile image
Kai Mercer •

One thing in the banner snippet: navigator.language.slice(0, 2) cuts three-letter language codes wrongly. I ran it in Node v22: "fil-PH".slice(0, 2) gives "fi" (Finnish) and "yue-HK" gives "yu", while new Intl.Locale("fil-PH").language gives "fil" and new Intl.Locale("yue-HK").language gives "yue". So a Filipino browser could be offered the Finnish page if you have one. The same slice also drops the script and region, so zh-Hant-TW and zh-CN both become "zh". Parsing with Intl.Locale and matching the full tag first (then the language only) avoids both, and it lines up with how hreflang values like zh-Hant are written.