Language Detection and Redirects at the Edge

A multilingual static site has one request it cannot answer well at build time: a visitor typing the bare domain. Every other URL carries a language — /de/guide/install/ is German by definition — but / could reasonably be English, German, French or Japanese. The browser tells you which the visitor prefers in the Accept-Language header, and a small edge function can use it. Done carelessly, the same function breaks shared links, hides locales from search engines and traps readers in a language they did not choose.

This guide builds a Cloudflare Worker that redirects the root URL only, respects an explicit choice stored in a cookie, never caches a personalised redirect, and leaves every other request to the static assets. It is part of Internationalization for Static Sites and uses the same approach as Serverless Functions for Static Sites: static everywhere, a few lines of code on one route.

Prerequisites

  • A static site deployed to Cloudflare Workers with static assets (or Pages with Functions), with locales under /de/, /fr/ and /ja/, and English at the root.
  • A language switcher on every page — see i18n Routing in Astro or Multilingual Sites with Hugo.
  • Wrangler 3.80 or newer.

The Rules

Before code, the behaviour — every rule exists because breaking it caused a real problem somewhere:

  1. Only requests for exactly / are considered. Deep links are never redirected.
  2. If a lang cookie exists, it wins. A visitor who picked English on a German laptop stays in English.
  3. Otherwise, parse Accept-Language with quality values and pick the best supported locale.
  4. If the best match is the default language, serve the root page directly — no redirect.
  5. Redirects are 302 with Cache-Control: private, no-store and Vary: Accept-Language, Cookie.
  6. Requests without Accept-Language (most crawlers, monitoring probes) get the default page.
Decision logic of the root language redirect The root question is whether the path is exactly the root. Deep paths are always served as static files. For the root, a lang cookie takes priority and redirects to the chosen locale or serves English. Without a cookie, Accept-Language is parsed and a supported non-default language produces a 302 redirect. No header or no supported language serves the English root page. Deciding what a request for / receives Is the path exactly / ? No: any deep path Serve static file never redirected Yes, lang cookie set Honour the cookie 302 to /de/ or serve root Yes, header prefers de 302 to /de/ private, no-store Yes, no usable header Serve English root crawlers and probes
Only one path and one header combination ever produce a redirect; everything else is plain static delivery.

The Worker

// src/worker.js
const LOCALES = ['en', 'de', 'fr', 'ja'];
const DEFAULT = 'en';

function pickLocale(header) {
  if (!header) return null;
  const ranked = header.split(',').map((part) => {
    const [tag, ...params] = part.trim().split(';');
    const q = params.find((p) => p.trim().startsWith('q='));
    return { lang: tag.toLowerCase().split('-')[0], q: q ? parseFloat(q.split('=')[1]) : 1 };
  }).filter((x) => x.q > 0).sort((a, b) => b.q - a.q);
  return ranked.find((x) => LOCALES.includes(x.lang))?.lang ?? null;
}

function cookieLocale(cookie) {
  const m = /(?:^|;\s*)lang=([a-z]{2})/.exec(cookie || '');
  return m && LOCALES.includes(m[1]) ? m[1] : null;
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname !== '/' || request.method !== 'GET') {
      return env.ASSETS.fetch(request);
    }
    const chosen = cookieLocale(request.headers.get('Cookie'))
      ?? pickLocale(request.headers.get('Accept-Language'));
    if (!chosen || chosen === DEFAULT) {
      const res = await env.ASSETS.fetch(request);
      const out = new Response(res.body, res);
      out.headers.set('Vary', 'Accept-Language, Cookie');
      return out;
    }
    return new Response(null, {
      status: 302,
      headers: {
        Location: `/${chosen}/`,
        'Cache-Control': 'private, no-store',
        Vary: 'Accept-Language, Cookie'
      }
    });
  }
};
# wrangler.toml
name = "docs"
main = "src/worker.js"
compatibility_date = "2026-09-01"

[assets]
directory = "./dist"
binding = "ASSETS"
run_worker_first = ["/"]

run_worker_first = ["/"] limits the Worker to the root path; every other request is answered by the static asset layer without invoking code, so the rest of the site keeps its cache behaviour and costs nothing extra. On older setups without that option, the early return env.ASSETS.fetch(request) achieves the same result at the cost of an invocation per request.

The cookie is what makes the redirect respectful. The switcher writes it on click, before navigation:

<script type="module">
  document.querySelectorAll('nav[aria-label="Language"] a[hreflang]').forEach((a) => {
    a.addEventListener('click', () => {
      document.cookie = `lang=${a.hreflang}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`;
    });
  });
</script>

Without JavaScript, links still work; the visitor just is not remembered. If the site's Content Security Policy forbids inline scripts, ship this as a small external module or hash it, as in Hash-Based CSP for Inline Scripts in Astro.

Sequence of redirects across three visits On the first visit a browser preferring German requests the root and the Worker replies 302 to /de/. The visitor chooses English in the switcher, which sets the lang cookie to en. On the return visit the browser requests the root with the cookie and the Worker serves the English page without a redirect. First visit, explicit choice, return visit Browser Accept-Language de-DE Worker root path only Static assets dist/ GET / (no cookie) 302 Location /de/ GET /de/ served directly switcher click sets lang=en GET / with cookie lang=en fetch English root 200 English page
After one explicit choice the Accept-Language header is never consulted again for that visitor.

Testing It

Test the behaviour with curl before and after deploy:

curl -sI https://docs.example.com/ -H 'Accept-Language: de-DE,de;q=0.9,en;q=0.8' | grep -iE '^(HTTP|location|cache-control)'
# HTTP/2 302 · location: /de/ · cache-control: private, no-store
curl -sI https://docs.example.com/ -H 'Accept-Language: de-DE' -H 'Cookie: lang=en' | head -1
# HTTP/2 200
curl -sI https://docs.example.com/guide/install/ -H 'Accept-Language: ja' | head -1
# HTTP/2 200   (deep link never redirected)
curl -sI https://docs.example.com/ | head -1
# HTTP/2 200   (no header: crawler behaviour)

These four requests make a good smoke test after every deploy, alongside the checks in Running Smoke Tests Against a Preview URL. They also belong in the header and redirect suite described in Testing Redirects and Headers Before Deploy.

Why Not Detect Language in the Browser?

A client-side script could read navigator.languages on the English root page and call location.replace('/de/'). It needs no edge function and works on any static host, so it is worth explaining why it performs worse.

The visitor downloads, parses and paints the English page first, then navigates away. On a throttled mobile profile in Lighthouse, the German landing page's LCP moved from 1.4 s with the edge redirect to 2.6 s with the client-side redirect, because the browser paid for two documents and the second could not start until the first had executed its script. The English page briefly flashes before the German one appears, which looks broken. The redirect also cannot be seen by curl, monitoring probes or your own smoke tests, so its behaviour is harder to verify, and a Content Security Policy that blocks inline scripts silently disables it.

There are still cases where a browser-side hint is right: a dismissible banner on an English page saying "This page is available in Deutsch" when navigator.languages prefers German and an alternate exists. That leaves the reader in control, never navigates without consent, and works on any deep link, which the edge redirect deliberately does not touch. The banner reads the page's own alternate links to find the target, sets the lang cookie when accepted, and remembers dismissal in localStorage. On this site it converted 22% of German-preferring visitors landing on English deep links, with no effect on the English bounce rate.

The combination — edge redirect on /, a consent banner on deep links, and a switcher everywhere — covers every entry point without ever moving a reader who did not ask to move.

Measured Impact

Four weeks per variant on the four-language documentation site, from first-party analytics recording browser language and first-page language:

MeasureNo redirectRoot-only WorkerEvery-URL redirect
German-preferring visitors reaching German on first view31%74%79%
Shared links opened in a different language than sent0%0%12%
Worker invocations per day0about 2,900about 61,000
Median added latency on / (Worker CPU + fetch)—3 ms3 ms
Crawled German URLs in Search Console612618402
Daily Worker invocations for root-only and every-URL redirects Horizontal bars. The root-only Worker, limited with run_worker_first, ran about 2,900 times a day. Redirecting every URL ran about 61,000 times a day because every page view invoked code. Worker invocations per day by strategy Root-only with run_worker_first ~2,900 / day Every URL redirected ~61,000 / day
Limiting the Worker to one path keeps the rest of the site as pure static delivery with no per-request code.

The every-URL variant slightly improved first-view language matching but cost a third of crawled German URLs — Googlebot, crawling without a German preference, kept being bounced to English — and broke one shared link in eight. The root-only Worker got nearly all of the benefit with none of the damage.

Pitfalls & Rollback

  • Caching the redirect. Without private, no-store and Vary, a CDN or browser can serve the German redirect to everyone.
  • Using 301. A permanent redirect is cached by browsers indefinitely; a visitor who later switches language keeps being sent back.
  • Matching region tags literally. de-AT should match de; strip the region before comparing, as the parser above does.
  • Redirecting HEAD requests or bots with a header. Some link previewers send Accept-Language; that is fine for the root, but never extend the rule to deep links.
  • Rollback: delete the run_worker_first entry (or the Worker route) and redeploy; the root immediately serves the English page as a plain static file.

Conclusion

Language detection belongs on exactly one URL. A Worker that runs only for /, prefers a saved cookie over the browser header, and returns uncacheable 302s gives most first-time visitors their language without touching shared links, crawlers or the static delivery of every other page. On the documentation site it raised German first-view matching from 31% to 74% for 3 ms of added latency on one route.

FAQ

Why redirect only the root URL and not every page?

A deep link was shared or found in a specific language, and changing it based on the visitor's browser breaks the sender's intent, confuses crawlers that send no language header, and stops readers from viewing a page in a language their browser does not prefer. The root URL carries no language intent, so it is the one safe place to guess.

Should the redirect be 301 or 302?

Use 302 or 307. The target depends on the visitor, so a permanent redirect cached by browsers or CDNs would send later visitors to the wrong language. Also mark the response as uncacheable or vary it on the headers used.

How does the visitor override the detected language?

The language switcher sets a cookie with the chosen locale. The edge function checks the cookie first and only falls back to Accept-Language when no cookie exists, so an explicit choice always wins.

Does this hurt SEO?

Not when done this way. Crawlers requesting the root without a cookie get the default-language page or a redirect to it, every localized URL remains directly reachable, and hreflang alternates tell search engines about all versions.

Filed underHow-tosCloudflare