Locale Detection & Edge Routing
Multilingual sites want to greet each visitor in the right language, and edge functions make it tempting to decide on every request: read the browser’s languages, look up the country from the IP address, and redirect. Done that way, locale detection causes more problems than it solves. Readers who follow a link to a German page get bounced to French because of a browser setting, crawlers see only one language, shared links behave differently for every recipient, and caches fill with variants. This topic describes locale detection that helps without harming: decisions only where the URL does not name a locale, explicit preferences that win over guesses, suggestions instead of forced redirects, and caching that stays safe. It belongs to Localization & SEO Optimization.
The principle behind everything here is that the URL is the source of truth. A URL with a locale, /de/preise, always serves that locale, to every visitor and crawler. Detection only decides what to do with URLs that do not carry a locale, such as the bare domain, and whether to suggest another language to a reader who might prefer it.
Core Concepts
Locale in the URL. Every localized page has a URL that names its locale, through a path prefix, subdomain or country domain. This is what makes pages linkable, crawlable and cacheable per locale.
Locale-less entry points. The bare domain, old unprefixed URLs and campaign links without a locale. These are the only places where detection decides where to go.
Accept-Language. The browser’s ordered list of preferred languages with quality weights, such as de-CH, de;q=0.9, en;q=0.8. A good signal of the reader’s language, not of their region.
Preference cookie. A cookie set when the reader explicitly chooses a language or accepts a suggestion. Stronger than any guess, and it should win.
Geolocation. The country derived from the IP address by the CDN. A signal about region, useful for regional variants, currencies and legal content, and a poor signal of language: many countries have several languages, and travellers and expatriates are common.
Integration Contract
Detection involves the edge, the router, the language switcher and analytics, and they must agree on a few rules. Supported locales and defaults come from the shared routing configuration, the same one that drives the route manifest and hreflang. Precedence is fixed: explicit choice in the URL, then the preference cookie, then Accept-Language, then geolocation where it applies, then the default. Redirect type is a temporary redirect, 302 or 307, from locale-less entry points only, never a permanent one, since the target depends on the visitor. Caching never varies a locale-prefixed page by detection signals; only the small set of locale-less entry points is affected.
# .env: locale detection
SUPPORTED_LOCALES=en,de,fr,es,ja
DEFAULT_LOCALE=en
LOCALE_COOKIE=NEXT_LOCALE
LOCALE_COOKIE_MAX_AGE_DAYS=365
GEO_REGION_HINTS=true # use country only to pick regional variants, not languages
Parsing Accept-Language Properly
The header is a weighted list, and naive parsing takes only the first value or matches substrings. Parse it into language ranges with quality values, sort by quality, and match each against supported locales, first exactly, then by the base language. A header of de-AT, de;q=0.9, en;q=0.7 on a site with de and en should select de, even though de-AT is not supported. Ignore wildcard entries and anything with quality zero.
// lib/i18n/accept-language.ts
export function pickLocale(header: string | null, supported: string[], fallback: string): string {
if (!header) return fallback;
const ranges = header
.split(",")
.map((part) => {
const [tag, ...params] = part.trim().split(";");
const q = params.find((p) => p.trim().startsWith("q="));
return { tag: tag.trim().toLowerCase(), q: q ? Number(q.split("=")[1]) : 1 };
})
.filter((r) => r.tag && r.tag !== "*" && r.q > 0)
.sort((a, b) => b.q - a.q);
const lower = supported.map((s) => s.toLowerCase());
for (const { tag } of ranges) {
const exact = lower.indexOf(tag);
if (exact !== -1) return supported[exact];
const base = tag.split("-")[0];
const byBase = lower.findIndex((s) => s === base || s.startsWith(`${base}-`));
if (byBase !== -1) return supported[byBase];
}
return fallback;
}
Redirecting Only the Entry Points
Detection runs in edge middleware for requests whose path has no locale. It picks a locale by precedence and responds with a temporary redirect to the same path under that locale. Requests that already have a locale pass through untouched, apart from the optional suggestion described below.
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { pickLocale } from "@/lib/i18n/accept-language";
const SUPPORTED = ["en", "de", "fr", "es", "ja"];
const DEFAULT = "en";
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const first = pathname.split("/")[1];
if (SUPPORTED.includes(first)) return NextResponse.next(); // the URL decides
const cookie = req.cookies.get("NEXT_LOCALE")?.value;
const locale = cookie && SUPPORTED.includes(cookie)
? cookie
: pickLocale(req.headers.get("accept-language"), SUPPORTED, DEFAULT);
const url = req.nextUrl.clone();
url.pathname = `/${locale}${pathname === "/" ? "" : pathname}`;
const res = NextResponse.redirect(url, 307);
res.headers.set("Vary", "Accept-Language, Cookie"); // only this redirect varies
res.headers.set("Cache-Control", "private, no-store"); // never cache per-visitor redirects at the CDN
return res;
}
export const config = { matcher: ["/((?!_next|api|favicon.ico|robots.txt|sitemap).*)"] };
Crawlers usually send no Accept-Language header or a fixed one, so they are redirected to the default locale from the bare domain and discover the other locales through hreflang, sitemaps and links, which is exactly what they need. The root redirect guide covers alternatives such as serving a language selector at the root.
Suggesting Instead of Redirecting
When a reader lands on a locale-prefixed page whose language differs from their preference, do not redirect. Show a small, dismissible banner, in the reader’s preferred language, offering the same page in that language if it exists: “Diese Seite ist auch auf Deutsch verfügbar.” The banner is rendered client-side from the Accept-Language header or the cookie, or from a small edge-computed hint, so the page itself stays identical for every visitor and cacheable. When the reader accepts, set the preference cookie and navigate; when they dismiss, remember the dismissal for the session. This respects deliberate choices, such as a German speaker reading the English documentation on purpose, while still helping readers who arrived through a link in the wrong language.
Remembering Choices
The preference cookie is what turns detection from a guess into a respected choice. Set it whenever the reader makes an explicit language decision: choosing a language in the switcher, accepting a suggestion banner, or picking from a language selector at the root. Do not set it merely because a reader visited a page in some locale, since readers follow links across languages without meaning to change their preference. Give the cookie a long lifetime, a year is common, and make it readable at the edge so entry-point redirects can use it. Signed-in users can have their preference stored in their profile too, so it follows them across devices, with the cookie as a fast local copy. Respect privacy rules: a functional preference cookie is generally allowed without consent in most jurisdictions, but check your own consent requirements and document the cookie in the privacy notice.
Old URLs Without Locales
Sites that added locale prefixes later often have old, unprefixed URLs in bookmarks, external links and search results. These are locale-less entry points too, and they deserve care. If the old URL corresponds to one specific page, redirect it permanently to that page in the locale it originally served, usually the default, because that mapping does not depend on the visitor. Only the bare domain and genuinely language-neutral entry points, such as campaign short links, should use visitor-dependent temporary redirects. Mixing the two, for example redirecting old English article URLs to the visitor’s detected language, sends German readers of an English link to a German fallback page and breaks the signals that old links carry.
Caching & Invalidation
Locale detection interacts with caching in two places. Redirects from locale-less entry points depend on per-visitor headers, so they must not be cached at the CDN under a shared key; mark them private, no-store, or configure the CDN to include the detection result in the cache key for those few URLs only. Locale-prefixed pages must not vary on Accept-Language, cookies or country at all, or the cache fragments into thousands of variants and hit rates collapse. Suggestion banners are computed outside the cached HTML for the same reason. With these rules, detection affects only a handful of entry URLs, and every content page caches exactly once per locale.
Preview & Draft Handling
Editors in preview need to reach any locale directly, regardless of their browser settings or location. Disable redirects and suggestions in draft mode, or at least for authenticated preview sessions, and make preview links always locale-prefixed. Otherwise a German editor reviewing the French draft is bounced to the German preview, which wastes time and causes confusion about what was published.
Error Handling & Resilience
Detection must never fail a request. Treat missing or malformed Accept-Language headers as absent, invalid cookie values as absent, and unavailable geolocation as absent, falling back to the default locale. Guard against redirect loops: a redirect should only be issued for paths without a locale, and the target always has one, so a loop indicates a configuration error, such as a locale missing from the supported list in one place but not another. Log redirect decisions with the signals used, sampled, so you can see how many visitors are routed by cookie, header, geolocation or default.
Testing & Observability
Test the middleware with a matrix of inputs: paths with and without locales, cookies set and unset, headers with regional tags, quality values, wildcards and unsupported languages, and assert the response for each. End-to-end, request the bare domain with several Accept-Language headers and a crawler user agent, and check the redirects. In production, track the share of entry-point redirects by signal, the acceptance and dismissal rates of suggestion banners, and the proportion of sessions that switch language after landing, which indicates how often detection or links send readers to the wrong language.
Worked Example
A travel booking site redirected every request by IP country to a matching locale. Swiss visitors were sent to German regardless of whether they spoke French or Italian, expatriates could not reach their own language without changing VPN settings, and search engines, crawling from the US, indexed only English pages, which suppressed the other languages in search. The team changed detection to run only on the bare domain, with precedence of cookie, Accept-Language and then default, used geolocation only to preselect the currency, and added suggestion banners on locale-prefixed pages. Indexed pages in non-English locales rose sharply within two months, and language switches in the first minute of visits fell from 14 to 3 percent. Support tickets from expatriates who could not reach their language disappeared entirely.
Detection in Static and Multi-Platform Setups
Not every site has edge middleware. Fully static sites on simple hosting can still implement the same rules. The bare domain can serve a small language selector page, which is often the better choice anyway, with a script that reads the preference cookie or navigator.languages and forwards the reader, while crawlers and readers without JavaScript see the selector’s links. Many hosting platforms and CDNs also support redirect rules based on Accept-Language or country for specific paths, which covers the root redirect without custom code. Mobile apps that deep-link into the site should always include the locale in links, since they know the app’s language setting. And for multi-tenant platforms, detection runs after tenant resolution, using the tenant’s own supported locales and defaults from the tenant registry, as described in resolving tenants at the edge.
Ownership and Review
Detection rules are small but have large effects on where readers land, so review them with product, SEO and market teams, not only engineering. Write the precedence rules and redirect behaviour down in one place next to the configuration, and revisit them when adding locales or regions. Track the metrics described above in a shared dashboard, and treat a rise in early language switches or a drop in indexed pages for a locale as a signal to review detection before looking elsewhere.
Regional Content Versus Language
Language and region are different questions, and many sites conflate them. A French speaker in Canada, Switzerland or Belgium wants French, but prices, shipping and legal terms depend on the country. Keep language in the URL and treat region as a separate dimension where it matters: a country selector or a geolocation-based default for currency and shipping, stored in its own cookie and applied to prices and availability rather than to the language of the page. Where regional differences are large enough to need separate pages, use regional locales such as fr-CA and fr-CH with their own URLs and hreflang, as described in regional variants and x-default. Either way, the reader can always override the guess, and the override sticks.
Common Anti-Patterns
A few patterns recur on multilingual sites and are worth naming so they can be avoided. Redirecting every request by IP, which hides languages from crawlers and traps travellers. Serving different languages at the same URL based on headers, which makes pages uncacheable and unshareable, since the recipient of a link may see a different language than the sender. Permanent redirects from the root, which browsers remember, so a reader who once arrived with an English browser is sent to English forever. Ignoring explicit choices, where the switcher changes the page but the next visit to the root detects the browser language again. Hiding the language switcher in a footer or behind a flag icon, which punishes readers whenever detection guesses wrong. Each of these is easy to introduce with a few lines of middleware and hard to notice in testing, because developers usually browse in one language from one country.
Frequently Asked Questions
Should the bare domain redirect or show a language selector?
Either works. A redirect gets most readers straight to content; a selector page avoids wrong guesses and can serve as x-default. Sites with evenly split audiences often prefer the selector.
Is it acceptable to redirect by IP country?
Only for locale-less entry points, and only as the last signal after the cookie and the Accept-Language header. Never redirect locale-prefixed URLs by IP.
Why a temporary redirect for the root?
Because the target depends on the visitor. A permanent redirect would be cached by browsers and crawlers as if it applied to everyone.
Do suggestion banners hurt Core Web Vitals?
Not if they overlay the page or are placed in reserved space, and are rendered after the main content.
Should the language switcher remember the choice?
Yes. An explicit choice in the switcher is the clearest signal a reader gives, so set the preference cookie when it is used, and let it override Accept-Language at every future entry point.
What about readers who use browser translation?
Some readers prefer to read the original language with their browser translating. Respect that by never redirecting locale-prefixed pages; the suggestion banner offers the native version without forcing it.
How do crawlers discover non-default locales?
Through hreflang annotations, sitemaps and links, including the language switcher, never through detection or redirects.