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:
- Only requests for exactly
/are considered. Deep links are never redirected. - If a
langcookie exists, it wins. A visitor who picked English on a German laptop stays in English. - Otherwise, parse
Accept-Languagewith quality values and pick the best supported locale. - If the best match is the default language, serve the root page directly — no redirect.
- Redirects are
302withCache-Control: private, no-storeandVary: Accept-Language, Cookie. - Requests without
Accept-Language(most crawlers, monitoring probes) get the default page.
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 Switcher Sets the Cookie
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.
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:
| Measure | No redirect | Root-only Worker | Every-URL redirect |
|---|---|---|---|
| German-preferring visitors reaching German on first view | 31% | 74% | 79% |
| Shared links opened in a different language than sent | 0% | 0% | 12% |
| Worker invocations per day | 0 | about 2,900 | about 61,000 |
Median added latency on / (Worker CPU + fetch) | — | 3 ms | 3 ms |
| Crawled German URLs in Search Console | 612 | 618 | 402 |
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-storeandVary, 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-ATshould matchde; 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_firstentry (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.
Related
- Parent: Internationalization for Static Sites — where the redirect fits in the i18n setup.
- hreflang and Language Alternates on Static Sites — the search-engine side of language targeting.
- Proxying Third-Party APIs from an Edge Function — another single-route Worker pattern.
- Configuring Redirects on Cloudflare Pages — static redirects that need no code.
- Migrating from Cloudflare Pages to Workers Static Assets — the platform the Worker runs on.
Filed underHow-tosCloudflare