Designing a Language Switcher for Headless Sites

Within Locale Detection & Edge Routing, this guide covers the one piece of localization every reader touches directly: the language switcher. A good switcher takes readers to the same page in another language when it exists, names each language in a form its speakers recognize, remembers the choice, and works for keyboard and screen reader users, crawlers and readers without JavaScript. A poor one sends everyone to a homepage, uses flags, and forgets the choice on the next visit.

In a headless site, the switcher needs data that lives in several places: which languages the current page exists in, and each version’s path, which may differ because slugs are translated. That information is the same as the page’s hreflang cluster, and building the switcher from the cluster is the simplest way to make it correct.

Switcher links from the page's clusterThe page's hreflang cluster lists the languages the page exists in with their paths; the switcher links those languages to the equivalent pages, links languages without a version to their homepages, marks the current language, and sets the preference cookie through a small route when a language is chosen.Page clusterlanguages + pathsSwitcherEquivalent pagefor listed languagesLocale homepagefor other languagesSet cookiethen navigateon choice
The switcher and hreflang share one source, so readers and crawlers see the same alternates.

The Problem

A fashion retailer’s switcher was a row of flags in the footer, each linking to the homepage of that country’s site. Readers on a product page who switched language lost their place and had to search for the product again. Spanish speakers in Mexico had to choose between a Spanish flag and a Mexican flag without knowing which meant what. The choice was not remembered, so the next visit to the root redirected by browser language again. Screen reader users heard “link, image” for each flag, because the images had no alternative text.

What a Good Switcher Does

Links to the equivalent page. For each language in which the current page exists, link to that version’s path. For languages without a version, either link to the locale’s homepage with a hint, or leave them out; never link to a fallback page that shows the same language the reader is leaving.

Names languages in their own language. “Deutsch”, “Français”, “日本語”, not “German”, “French”, “Japanese” in the current page’s language, because a reader looking for their language recognizes its own name. Add the region only for regional variants: “English (UK)”, “Español (México)”.

Avoids flags for languages. Flags represent countries, not languages. They mislead in multilingual countries and for languages spoken in many countries. Use them only for country selectors, alongside text.

Remembers the choice. Selecting a language sets the preference cookie, so the root redirect and suggestion banners respect it on future visits.

Is accessible and crawlable. Plain links with hreflang and lang attributes, keyboard-operable disclosure if it is a dropdown, and a clear label.

Switcher patterns comparedA visible list of links, a disclosure dropdown and a flag grid compared on discoverability, accessibility, crawlability and suitability for many languages.PatternDiscoverableAccessibleMany languagesInline list of linksyesyesgets longDisclosure menu with linksone clickif built rightyesFlag gridyesoften notambiguousSelect element with JS navigationyesokyes, but not crawlable
Links in a list or a disclosure menu serve readers and crawlers; flags serve neither well.

Implementation

The switcher is a server component that receives the page’s cluster and renders links. The native details element provides an accessible disclosure without JavaScript.

TSX
// components/language-switcher.tsx
interface Version { locale: string; href: string }

const NAMES: Record<string, string> = { en: "English", "en-GB": "English (UK)", de: "Deutsch", fr: "Français", es: "Español", "es-MX": "Español (México)", ja: "日本語" };
const LABEL: Record<string, string> = { en: "Language", de: "Sprache", fr: "Langue", es: "Idioma", ja: "言語" };

export function LanguageSwitcher({ current, versions, allLocales }: { current: string; versions: Version[]; allLocales: string[] }) {
  const byLocale = new Map(versions.map((v) => [v.locale, v.href]));
  return (
    <details className="lang-switcher">
      <summary aria-label={LABEL[current] ?? "Language"}>
        <span lang={current}>{NAMES[current] ?? current}</span>
      </summary>
      <ul>
        {allLocales.map((locale) => {
          const href = byLocale.get(locale);
          const target = href ?? `/${locale.toLowerCase()}/`;
          return (
            <li key={locale}>
              <a
                href={`/api/set-locale?locale=${encodeURIComponent(locale)}&to=${encodeURIComponent(target)}`}
                hrefLang={locale}
                lang={locale}
                aria-current={locale === current ? "true" : undefined}
              >
                {NAMES[locale] ?? locale}
                {!href && <span className="lang-switcher__hint"> ↗</span>}
              </a>
            </li>
          );
        })}
      </ul>
    </details>
  );
}

The link goes through a tiny route that sets the preference cookie and redirects, so the choice is remembered even without JavaScript. The route must only accept targets on the same site, or it becomes an open redirect.

TypeScript
// app/api/set-locale/route.ts
import { NextResponse } from "next/server";

const SUPPORTED = new Set(["en", "en-GB", "de", "fr", "es", "es-MX", "ja"]);

export function GET(req: Request) {
  const url = new URL(req.url);
  const locale = url.searchParams.get("locale") ?? "";
  const to = url.searchParams.get("to") ?? "/";
  if (!SUPPORTED.has(locale) || !to.startsWith("/") || to.startsWith("//")) return new Response("bad request", { status: 400 });
  const res = NextResponse.redirect(new URL(to, url.origin), 303);
  res.cookies.set("NEXT_LOCALE", locale, { maxAge: 60 * 60 * 24 * 365, sameSite: "lax", secure: true, path: "/" });
  return res;
}

Crawlers following switcher links through the cookie route would see redirects rather than direct alternates. Mark the route noindex in its response headers and disallow it in robots.txt, and rely on hreflang for crawler discovery; alternatively, link directly to the target pages and set the cookie with a small script on click, keeping the links crawlable.

Placement

Put the switcher where readers look for it: in the header, at the top right in left-to-right layouts and top left in right-to-left ones, and repeat it in the footer. Use a globe or language icon together with the current language’s name, since the icon alone is ambiguous. On mobile, keep it in the menu but not buried under several levels.

Switching on pages with state

Some pages carry state that a naive switch loses: search results with a query, filtered listings, a checkout in progress, or a form half filled. Preserve what can be preserved. Carry query parameters that are language-neutral, such as filters by id, sort order and pagination, into the target URL. Translate search queries only if the search supports it; otherwise land on the target locale’s search page with the query prefilled so the reader can adjust it. In checkout and account areas, switch the interface language without leaving the flow, because the cart and form data belong to the session, not the page. These cases are rare compared with ordinary content pages, but they are where a careless switcher costs the most.

Configuration Reference

Aspect Recommendation Why
Targets equivalent page when it exists Readers keep their place.
Language names endonyms, region only for variants Readers recognize their own language.
Flags not for languages Countries are not languages.
Markup links with hreflang and lang Accessible and crawlable.
Choice stored in a preference cookie Future visits respect it.
Cookie route same-site targets only No open redirect.

Gotchas & Edge Cases

  • Switcher from fallback pages. On a fallback page, the current language’s version does not exist. Mark the current language, but build links from the source page’s cluster.
  • Unsupported language names. Keep endonyms in code or configuration, not in the CMS, so they do not depend on translation workflows.
  • Right-to-left names. Arabic or Hebrew names in a left-to-right list need lang and, where needed, dir="rtl" on the link text.
  • Caching. The switcher’s links depend on the page, not on the visitor, so the page stays cacheable. Do not personalize the switcher per visitor in the HTML.

Worked Example

The fashion retailer replaced its footer flags with a header disclosure menu listing languages by their own names, linking to the same product in each language when it existed, and setting a preference cookie through a small route. Regional Spanish variants were labelled “Español (España)” and “Español (México)”. The share of language switches that ended on a homepage fell from all of them to about one in ten, for products not available in the chosen market, and support stopped receiving requests about how to find a product after switching language.

Language switches landing on the equivalent pageShare of language switches from product pages that landed on the same product in the chosen language, before and after the new switcher.Flags to homepages0 % of switchesCluster-based links89 % of switches
Readers now keep their place when they change language.

Testing the Switcher

Test the switcher’s links with the same fixtures used for hreflang: a page translated into all languages, some languages and one language, with translated slugs. Assert that each link points at the correct equivalent path or homepage, that the current language is marked, and that links carry hreflang and lang. Keyboard-test the disclosure: it must open with Enter or Space, links must be reachable with Tab, and focus must be visible. Test the cookie route with valid and invalid targets, including protocol-relative and absolute external URLs, which must be rejected. Finally, test the full loop in a browser: switch language, return to the root, and confirm the chosen language is used.

Rollout Checklist

  • Build switcher links from the page’s hreflang cluster.
  • Name languages in their own language; add regions only for variants.
  • Use links with hreflang and lang, in an accessible disclosure.
  • Remember explicit choices in a preference cookie.
  • Prevent open redirects in the cookie route.
  • Test links, keyboard access and the full remember-and-return loop.

Frequently Asked Questions

Should the switcher show languages without a version of the page?

Showing them lets readers reach that locale’s homepage; hiding them avoids disappointment. Showing them with a clear hint is a reasonable compromise.

Can we use a select element?

A select that navigates with JavaScript works for readers but is not crawlable and is less accessible. Links are better.

Should the switcher translate its own label?

Yes, into the current page’s language, while the language names stay in their own languages.

Where should country selection go?

In a separate control when region affects prices, shipping or legal content. Mixing language and country in one list confuses both choices.