Locale detection strategies: URL, Cookie, or Header?

How does your app know which language to show?
It sounds like a simple question. In practice, it is one of the most consequential architectural decisions in a multilingual application: one that touches SEO, caching, user experience, backend architecture, and deployment pipelines all at once.
Get it right and locale detection is invisible. Users arrive, see their language, and get on with their day. Get it wrong and you end up with users who can't bookmark a localized URL, search engines that index only one language, hydration mismatches between server and client, or a language selector that resets itself every session.
This guide covers every major locale detection strategy in depth: how each one works, where it excels, where it breaks down, and which combinations actually work in production. It builds on our broader internationalization guide, which covers the full picture of building multilingual software.
Quick reference: the full comparison
| Strategy | SEO | Caching | Shareability | First-visit UX | Search intent | Complexity |
|---|---|---|---|---|---|---|
| URL path | Excellent | Simple | Perfect | Requires redirect | High (indexed) | Low |
| Subdomain | Good | Good | Good | Requires redirect | High (indexed) | High |
| Cookie | None | Complex | None | Immediate | None (app-state) | Medium |
| Accept-Language | None | Vary header | None | Automatic | None (app-state) | Low |
| IP geolocation | None | Simple | None | Inaccurate | None (app-state) | Medium |
For most public-facing web apps the answer is: URL path + Accept-Language for first-visit redirect + cookie for explicit user preference. The rest of this guide explains why, covers every edge case, and shows you how to implement it.
What is locale detection?
Locale detection is the mechanism your application uses to determine which locale to serve for a given request or user session.
A locale is more than a language. It is a combination of language, region, and formatting conventions: en-US (American English), pt-BR (Brazilian Portuguese), and de-AT (Austrian German) are three distinct locales, each with its own date formats, number separators, and in some cases vocabulary and formality norms.
Most applications need to detect locale at multiple layers:
- The HTTP layer (routing, middleware, CDN): before a page renders
- The rendering layer (component tree, i18n library): while a page renders
- The persistence layer (database, user profile): for returning users
Each layer has different constraints and different tools available. A complete locale detection strategy coordinates all three.
For a deeper look, check out our blog on the differences between language and locale.
The five primary locale detection strategies
1. URL path segments
The locale is encoded as a segment in the URL path:
https://example.com/en/pricing
https://example.com/de/pricing
https://example.com/pt-BR/pricing
This is the most common and most recommended approach for public-facing web applications. The locale is explicit, visible, and carried with every request.
How it works
In frameworks like Next.js, the locale prefix is handled at the routing level. In Next.js App Router, you define supported locales and create a [locale] dynamic segment:
app/
[locale]/
page.tsx
pricing/
page.tsx
Middleware intercepts the request before rendering:
// middleware.ts (Next.js App Router)
import { NextRequest, NextResponse } from 'next/server';
const supportedLocales = ['en', 'de', 'fr', 'pt-BR'];
const defaultLocale = 'en';
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Check if locale is already in path
const pathnameHasLocale = supportedLocales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) return;
// Detect from Accept-Language header as fallback
const acceptLanguage = request.headers.get('accept-language') ?? '';
const detectedLocale = detectLocale(acceptLanguage, supportedLocales, defaultLocale);
// Redirect to the locale-prefixed URL
request.nextUrl.pathname = `/${detectedLocale}${pathname}`;
return NextResponse.redirect(request.nextUrl);
}
For a Vue/Nuxt application using @nuxtjs/i18n, locale-prefixed routing is configured in nuxt.config.ts:
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
locales: ['en', 'de', 'fr'],
defaultLocale: 'en',
strategy: 'prefix_except_default',
}
})
With prefix_except_default, /pricing serves English and /de/pricing serves German: keeping the default locale URL clean while still making all other locales explicit.
What it does well
-
SEO is the headline advantage
Search engines treat each URL as a distinct page. Google can index
/en/pricingand/de/pricingseparately, with the appropriate hreflang annotations pointing each locale at the others. Without unique URLs per locale, you cannot have proper multilingual SEO.<!-- hreflang annotations in <head> --> <link rel="alternate" hreflang="en" href="https://example.com/en/pricing" /> <link rel="alternate" hreflang="de" href="https://example.com/de/pricing" /> <link rel="alternate" hreflang="fr" href="https://example.com/fr/pricing" /> <link rel="alternate" hreflang="x-default" href="https://example.com/en/pricing" /> -
SEO and hreflang
If you use URL path segments, you must also implement
<link rel="alternate" hreflang="..." />tags so search engines understand the relationship between/en/pricingand/fr/pricing. Without hreflang, Google may treat your locale pages as duplicate content rather than translations of each other. For a full walkthrough, see What is hreflang and how to use it. -
Cacheability
CDNs cache by URL. With locale in the path, each language version of a page is a distinct cache entry. No need to vary on headers, no cache poisoning risk.
-
Shareability and bookmarking
A localized URL is a stable reference. A German user can share
/de/pricingand the recipient lands in German: no cookie lookup, no header inference, no ambiguity. -
Explicit state
Developers and QA teams can switch between locale versions by editing the URL. Debugging a translation issue in Japanese is as simple as navigating to
/ja/.
Where it breaks down
The locale appears in the URL, which means changing locale changes the URL. This is a non-issue for most cases: it is actually desirable: but it does mean breadcrumbs, canonical tags, and internal links all need locale-aware logic.
There is also a UX decision about what to do with the default locale:
| Strategy | Default locale URL | Non-default locale URL |
|---|---|---|
prefix_all | /en/pricing | /de/pricing |
prefix_except_default | /pricing | /de/pricing |
no_prefix | /pricing | /pricing (locale via other signal) |
prefix_except_default is usually the right choice. It keeps the main-language URL clean while still making all other locales explicit and SEO-friendly.
For a detailed breakdown of URL strategies and their SEO implications, see URLs in Localization: How to structure and optimize for multilingual websites.
2. Subdomain-based detection
The locale is encoded in the subdomain:
https://en.example.com/pricing
https://de.example.com/pricing
https://fr.example.com/pricing
How it works
This requires DNS configuration (a wildcard record or individual A/CNAME entries per locale) and server-side routing that inspects the Host header:
// Express.js example
app.use((req, res, next) => {
const host = req.hostname; // e.g., "de.example.com"
const subdomain = host.split('.')[0];
const supportedLocales = ['en', 'de', 'fr', 'pt'];
req.locale = supportedLocales.includes(subdomain) ? subdomain : 'en';
next();
});
What it does well
Subdomains give each locale an independent crawl budget and the ability to build locale-specific domain authority. This is meaningful for very large sites with significant organic traffic per language.
Where it breaks down
Subdomains add significant operational overhead: SSL certificates per subdomain, DNS propagation delays when adding locales, complex cookie sharing configuration (domain=.example.com), and CDN configuration that must be replicated per subdomain.
For most SaaS products, this overhead is not worth the benefit. Path-based locales achieve the same SEO goals with far less infrastructure complexity.
When subdomains make sense: large e-commerce platforms (Amazon uses
amazon.de,amazon.fr), enterprises with distinct legal entities per country, or applications where regional infrastructure isolation is a hard requirement.
3. Cookie-based detection
The user's locale preference is stored in a browser cookie after an explicit selection or initial detection:
Set-Cookie: locale=de; Path=/; Max-Age=31536000; SameSite=Lax
How it works
// Reading locale from cookie in Next.js middleware
export function middleware(request: NextRequest) {
const cookieLocale = request.cookies.get('locale')?.value;
const supportedLocales = ['en', 'de', 'fr'];
const locale = supportedLocales.includes(cookieLocale ?? '')
? cookieLocale
: 'en';
const response = NextResponse.next();
response.headers.set('x-locale', locale);
return response;
}
// Setting the cookie when user selects a language
function setLocaleCookie(locale: string) {
document.cookie = `locale=${locale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
window.location.reload();
}
What it does well
Cookies are the right tool for persisting a user's explicit locale choice across sessions. When a user clicks a language selector and picks German, that preference should survive browser restarts, tab closures, and return visits days later. Cookies are also useful for authenticated applications where SEO is irrelevant.
Where it breaks down
-
Cookies are not crawlable.
Search engines will not read a cookie to decide which language to index. If your only locale signal is a cookie, Googlebot sees one language for every URL.
-
GDPR and cookie consent is a consideration in European markets.
A "preference" cookie is typically classified as functional (not usually requiring explicit opt-in), but review with legal counsel for your jurisdiction.
Warning: Cookie-based locale is the #1 cause of hydration mismatches in Next.js and React apps. If the server reads a cookie and renders German, but the client JavaScript initializes from
navigator.languageand gets English, React finds a mismatch during hydration. The full diagnosis and fix is covered in the dedicated section below.
Pro tip: If you detect locale via cookies without a unique URL per locale, ensure your server sends
Vary: Cookiein the response headers. Without it, a CDN like Cloudflare might cache the German version and serve it to an English-speaking user hitting the same URL. The same applies toVary: Accept-Languagefor header-based detection. The safest fix is to redirect to a locale-prefixed URL so caching is handled naturally by distinct URLs.
The right role for cookies: persisting an explicit user selection, not as the primary detection mechanism.
4. Accept-Language header detection
Every browser sends an Accept-Language header with each HTTP request, listing the user's preferred languages in order of preference:
Accept-Language: de-AT,de;q=0.9,en-US;q=0.8,en;q=0.7
The q values are quality factors between 0 and 1 (higher means more preferred).
How it works
function detectLocaleFromHeader(
acceptLanguage: string,
supportedLocales: string[],
defaultLocale: string
): string {
if (!acceptLanguage) return defaultLocale;
const preferences = acceptLanguage
.split(',')
.map((item) => {
const [lang, q] = item.trim().split(';q=');
return { lang: lang.trim(), q: q ? parseFloat(q) : 1.0 };
})
.sort((a, b) => b.q - a.q);
for (const { lang } of preferences) {
if (supportedLocales.includes(lang)) return lang;
// Try matching just the language part (e.g., "de" for "de-AT")
const languageOnly = lang.split('-')[0];
const match = supportedLocales.find(
(l) => l === languageOnly || l.startsWith(`${languageOnly}-`)
);
if (match) return match;
}
return defaultLocale;
}
For production use, @formatjs/intl-localematcher implements the RFC 4647 lookup algorithm correctly and handles edge cases you will miss in a hand-rolled implementation:
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
const headers = { 'accept-language': 'de-AT,de;q=0.9,en;q=0.8' };
const negotiator = new Negotiator({ headers });
const languages = negotiator.languages();
const locale = match(languages, ['en', 'de', 'fr'], 'en');
// → 'de'
What it does well
Accept-Language provides a zero-friction first visit experience. The user arrives and immediately sees their preferred language: no interaction required, no state to look up.
Where it breaks down
The header reflects browser preferences, not necessarily the user's preferred language for your product. A developer using a German-language OS who reads English documentation will trigger German locale detection.
Search engines do not send Accept-Language in a meaningful way, so header-only detection cannot support multilingual indexing.
Common pitfall: the
Vary: Accept-LanguageCDN trap.
If you serve different content perAccept-Languagevalue without encoding the locale in the URL, you must setVary: Accept-Languageon your responses. This effectively kills CDN caching: every unique header value becomes a separate cache entry, collapsing hit rates near zero. The fix: after the first-visit negotiation, redirect to a locale-prefixed URL. The CDN then caches by URL normally and you never needVary: Accept-Language.
The right role for
Accept-Language: as a starting point on first visit, immediately confirmed into a URL redirect.
5. IP geolocation
The user's IP address is geolocated to a country, which is mapped to a default locale:
IP: 193.17.172.0 → Country: PL → Default locale: pl
IP: 8.8.8.8 → Country: US → Default locale: en-US
How it works
Cloudflare adds a CF-IPCountry header to every request automatically:
const country = request.headers.get('CF-IPCountry'); // e.g., "DE"
const countryToLocale: Record<string, string> = {
DE: 'de', FR: 'fr', PL: 'pl', BR: 'pt-BR', US: 'en-US',
};
const locale = countryToLocale[country ?? ''] ?? 'en';
Vercel Edge Functions expose the same data:
import { geolocation } from '@vercel/edge';
export function middleware(request: NextRequest) {
const { country } = geolocation(request);
// country → "DE", "FR", "PL", etc.
}
What it does well
Geolocation is genuinely useful for currency defaults, regional content, and compliance requirements rather than language. An e-commerce site can show local currency without needing language detection at all.
Where it breaks down
Country-to-language mapping is fundamentally imprecise. Switzerland has four official languages. Belgium has three. Canada has two. Singapore has four. A user geolocating to Belgium tells you almost nothing about French vs Dutch preference.
VPNs, corporate proxies, and cloud provider IPs all produce incorrect results. A developer in Warsaw on AWS eu-west-1 looks like they are in Ireland.
Warning: Country does not equal language. Never assume an IP address tells you what language a user speaks. A tourist in Japan still wants their UI in English. A bilingual Canadian may prefer French or English depending on context. Always prioritize the
Accept-Languageheader over IP-based geolocation for language selection.
The right role for IP geolocation: setting currency, surfacing regional promotions, and providing a fallback hint when all other signals are absent. Never as a hard locale lock.
Building hybrid strategies
No single detection method covers all cases well. Production systems combine methods to get the benefits of each while mitigating the weaknesses.
The detection priority stack
Work through this sequence on each request and stop at the first signal you find. The higher a signal is in the stack, the more it reflects an explicit user choice rather than an inferred guess.
1. Authenticated user profile (most reliable)
The user's stored locale preference from the database. The only signal that follows a user across devices and browsers.
2. URL locale segment
A valid locale prefix in the URL (/de/pricing). Explicit, shareable, and SEO-indexable.
3. Explicit preference cookie
A cookie set by a previous deliberate language selection. Reflects a choice the user made in an earlier session.
4. Accept-Language header
The browser's declared language preference. An inference: use it to make a redirect decision, not a final one.
5. IP geolocation
Country-to-locale mapping as a last resort before falling back to the application default.
6. Application default locale (last resort)
The ultimate fallback when all signals are absent or inconclusive.
What this looks like in middleware
// middleware.ts: Next.js App Router
import { NextRequest, NextResponse } from 'next/server';
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'pt-BR', 'ja'];
const DEFAULT_LOCALE = 'en';
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// 1️⃣ URL is the strongest signal — if locale is already in the path, trust it
const urlLocale = SUPPORTED_LOCALES.find(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (urlLocale) return NextResponse.next();
// 2️⃣ Cookie = explicit user choice from a previous session
const cookieLocale = request.cookies.get('user-locale')?.value;
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) {
return redirectToLocale(request, cookieLocale);
}
// 3️⃣ Accept-Language header = browser preference (best guess for first visit)
const acceptLanguage = request.headers.get('accept-language') ?? '';
const negotiatedLocale = negotiateLocale(acceptLanguage, SUPPORTED_LOCALES, DEFAULT_LOCALE);
// 4️⃣ If nothing matched, negotiateLocale already returns DEFAULT_LOCALE
return redirectToLocale(request, negotiatedLocale);
}
function redirectToLocale(request: NextRequest, locale: string) {
const url = request.nextUrl.clone();
url.pathname = `/${locale}${request.nextUrl.pathname}`;
// 307 (not 301) so the browser does not cache the redirect — users can switch locale later
return NextResponse.redirect(url, { status: 307 });
}
The first-visit flow in practice
User requests https://example.com/pricing
│
├─ Middleware: no URL locale, no cookie
├─ Read Accept-Language: "de-AT,de;q=0.9,en;q=0.8"
├─ Negotiate: de-AT → not supported → try 'de' ✓
├─ 307 Redirect to /de/pricing
└─ Page renders in German
User clicks language selector → selects French
│
├─ Set cookie: locale=fr; max-age=31536000
├─ Navigate to /fr/pricing
└─ Page renders in French
User returns next week
│
├─ Middleware: no URL locale, cookie=fr ✓
├─ 307 Redirect to /fr/
└─ Page renders in French (explicit preference respected)
The hydration mismatch problem
This is the most common technical failure specific to multilingual Next.js and React applications: and it is entirely avoidable.
What happens:
The server reads a locale signal (usually a cookie or header) and renders HTML in German. The client JavaScript initializes its i18n library from a different source: navigator.language, localStorage, or a hard-coded default: and resolves English. React compares the server-rendered HTML to the client render, finds a mismatch, and either throws a hydration error or silently re-renders in the wrong language. In plain terms: the server sends "Hallo" but the client expects "Hello", so React sees two different trees and throws a mismatch error or flashes the wrong language before correcting itself.
The root cause: locale is not being passed consistently from server to client.
Fix 1: Embed the locale in the HTML so the client reads from the same source:
// Server: serialize the detected locale into the page
<script>window.__LOCALE__ = 'de';</script>
// Client: always read from the embedded value first
const locale = window.__LOCALE__ ?? navigator.language ?? 'en';
i18n.changeLanguage(locale);
Fix 2 (structural): Use URL-based locale.
When the locale is encoded in the URL, the Next.js [locale] segment is available to both server and client components from the same source of truth. Server and client always agree. Hydration mismatches become structurally impossible.
// app/[locale]/layout.tsx: locale from URL params, consistent everywhere
export default async function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: string };
}) {
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}
This is one of the strongest structural arguments for URL-based locale as the primary signal: it eliminates an entire class of subtle, hard-to-reproduce bugs without any extra code.
Locale detection in static site generation (SSG)
Static sites have no server to detect locale at request time. All decision-making happens at build time or at the CDN/edge layer.
Build-time approach
Generate a complete set of static pages for each supported locale:
/en/pricing.html
/de/pricing.html
/fr/pricing.html
The root URL uses CDN-level redirect rules for locale negotiation:
# netlify.toml
[[redirects]]
from = "/pricing"
to = "/en/pricing"
status = 302
conditions = {Language = ["en"]}
[[redirects]]
from = "/pricing"
to = "/de/pricing"
status = 302
conditions = {Language = ["de", "de-AT", "de-CH"]}
Netlify's Language condition reads from the Accept-Language header at the CDN level: locale negotiation without a server.
Edge rendering approach
Frameworks like Astro, SvelteKit, and Next.js support edge runtime middleware running at CDN nodes globally, with response times under 10ms:
// Astro middleware (runs at edge)
import type { MiddlewareHandler } from 'astro';
export const onRequest: MiddlewareHandler = ({ request, redirect }) => {
const url = new URL(request.url);
const hasLocale = /^\/(en|de|fr|ja)(\/|$)/.test(url.pathname);
if (!hasLocale) {
const acceptLang = request.headers.get('accept-language') ?? 'en';
const locale = negotiateLocale(acceptLang);
return redirect(`/${locale}${url.pathname}`);
}
};
Regional variants and locale negotiation edge cases
The gap between de (generic German) and de-AT (Austrian German) is a common source of subtle bugs.
The negotiation waterfall
When your application supports de but a user's header says de-AT:
User: de-AT,de;q=0.9,en;q=0.8
Supported: [en, de, fr]
Step 1: Exact match for "de-AT" → not found
Step 2: Language-only match for "de" → found ✓
Step 3: Serve "de" locale
This is the correct behavior. Most i18n negotiation libraries implement it automatically. The risk is hand-rolled matching that only does exact comparisons and silently falls back to the default for any regional variant not in your list.
Chinese: a case study in getting variants right
The zh language tag splits into two distinct writing systems:
zh-Hans(Simplified Chinese): mainland China, Singaporezh-Hant(Traditional Chinese): Taiwan, Hong Kong, Macao
These are not interchangeable. A user in Taiwan sending zh-TW should be matched to zh-Hant, not zh-Hans. Getting this wrong is immediately visible to native readers: the vocabulary differences go well beyond regional spelling preferences.
const chineseVariantMap: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-SG': 'zh-Hans',
'zh-TW': 'zh-Hant',
'zh-HK': 'zh-Hant',
'zh-MO': 'zh-Hant',
};
function resolveChineseLocale(lang: string, supported: string[]): string | null {
const mapped = chineseVariantMap[lang];
if (mapped && supported.includes(mapped)) return mapped;
return null;
}
The same care applies to Spanish (es-MX vs es-ES), Portuguese (pt-BR vs pt-PT), and Serbian (Latin vs Cyrillic script). Always test your negotiation logic against the actual regional tags your target users send.
Surfacing locale suggestions without forced redirects
For content-heavy sites, an aggressive redirect on first visit can be jarring. A pattern used by MDN and Stripe Docs: detect the likely locale, but present it as a suggestion rather than a redirect.
function LocaleSuggestion({ detectedLocale, currentLocale }: {
detectedLocale: string;
currentLocale: string;
}) {
if (detectedLocale === currentLocale) return null;
return (
<div role="complementary" aria-label="Language suggestion">
<p>
This page is also available in{' '}
<a href={`/${detectedLocale}${window.location.pathname}`}>
{localeNames[detectedLocale]}
</a>
</p>
<button onClick={dismiss}>Stay in {localeNames[currentLocale]}</button>
</div>
);
}
This is especially valuable for documentation, where developers often prefer English even when accessing from non-English countries.
User-facing language selectors and locale persistence
Locale detection handles the first visit. After that, explicit user choice must override everything.
function handleLocaleChange(newLocale: string) {
// 1. Set persistence cookie (1 year)
document.cookie = [
`user-locale=${newLocale}`,
'path=/',
'max-age=31536000',
'SameSite=Lax'
].join('; ');
// 2. Navigate to the locale-prefixed version of the current page
const currentPath = window.location.pathname;
const pathWithoutLocale = currentPath.replace(/^\/(en|de|fr|ja)/, '') || '/';
window.location.href = `/${newLocale}${pathWithoutLocale}`;
// 3. If authenticated, update the server-side preference too
if (isAuthenticated) {
fetch('/api/user/locale', {
method: 'PATCH',
body: JSON.stringify({ locale: newLocale }),
});
}
}
For UX guidance on language selectors: including the flag debate, accessibility, and real examples from Duolingo, Canva, and Shopify: see Top language selector UX examples.
Decision framework: Which strategy should you use?
Decision tree
Is SEO important for your localized content?
│
├─ YES → You must use URL-based locale (path or subdomain)
│ │
│ ├─ Do you need separate domain authority per locale?
│ │ ├─ YES → Subdomain (amazon.de, amazon.fr)
│ │ └─ NO → Path segment (example.com/de/) ← recommended default
│ │
│ └─ Use Accept-Language for first-visit redirect
│ Use cookie to remember explicit user selection
│
└─ NO (authenticated app, internal tool, SaaS dashboard)
│
├─ Is the app server-rendered?
│ ├─ YES → Cookie in middleware + user profile persistence
│ └─ NO → navigator.language + localStorage for CSR apps
│
└─ Always allow explicit user override via language selector
Recommended defaults by app type
-
For most web applications and SaaS marketing sites:
URL path segments as the primary locale signal. Detect locale on first visit from theAccept-Languageheader and redirect to the appropriate locale-prefixed URL. Store explicit user selections in a cookie. -
For authenticated SaaS applications:
Store locale preference in the user profile in your database. Fall back to cookie, thenAccept-Languageheader, then application default. URL encoding is optional for behind-auth content but still useful for debugging and QA. -
For static sites:
Generate locale-specific pages at build time. Use CDN-level header negotiation (Netlify redirects, Vercel routing) for the root URL. Add a language selector with a cookie to override the initial detection. -
For mobile applications:
ReadLocale.current(iOS/macOS) orLocale.getDefault()(Android) for the device locale. Allow user override in settings, persisted to device storage and synced to the user profile on the backend.
Common implementation mistakes
Forgetting to handle the root URL
When / doesn't redirect to a locale, some users end up in an unlocalized state. Middleware must cover all paths:
// Wrong: only handles paths that already have a locale prefix
if (!pathname.startsWith('/en') && !pathname.startsWith('/de')) {
// redirect
}
// Right: redirect when no valid locale is present at all
const hasValidLocale = SUPPORTED_LOCALES.some(
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)
);
if (!hasValidLocale) {
// detect and redirect
}
Using permanent redirects (301) for locale negotiation
Browsers cache 301 redirects aggressively. If a German user visits your site on a shared computer and triggers a / → /de/ redirect cached as a 301, the next user on that computer always lands in German: until the browser cache expires, which could be weeks or months.
Always use 307 (Temporary Redirect) for locale negotiation. We use 307 instead of 301 so the browser doesn't cache the detection logic, allowing users to switch languages manually without being forced back by the browser cache. A 307 also preserves the original HTTP method (important for POST requests), while a 301 may change it to GET.
// This will cause hard-to-debug locale lock on shared machines
return NextResponse.redirect(url, { status: 301 });
// 307 preserves method and is not browser-cached
return NextResponse.redirect(url, { status: 307 });
This is one of the most common locale detection bugs in production: easy to miss in development because you are using your own machine, and hard to reproduce later.
Not canonicalizing duplicate locales
If example.com/pricing, example.com/en/pricing, and example.com/en-US/pricing all serve the same content, search engines see three pages competing with each other. Use canonical tags to point to one authoritative URL per locale:
<link rel="canonical" href="https://example.com/en/pricing" />
The Vary: Accept-Language CDN trap
If you detect locale from the Accept-Language header without encoding the result in the URL, you must set Vary: Accept-Language on responses. This header effectively disables CDN caching: every unique header value becomes a separate cache entry, collapsing hit rates near zero. The fix: redirect to a locale-prefixed URL after first-visit negotiation. Once the locale is in the URL, you do not need Vary: Accept-Language and the CDN can cache normally.
The full picture
Locale detection is one of those decisions that looks simple in a tutorial and reveals its complexity in production. A few principles that hold across all strategies:
-
Prefer explicit over inferred.
The URL is more reliable than a cookie, which is more reliable than a header, which is more reliable than geolocation. Build your priority stack in that order. -
Always allow user override.
Automatic detection is an educated guess. The user knows their language better than your server does. A language selector is not optional: it is a correctness mechanism. -
Design for debugging.
URL-based locales are the easiest to debug. You can test any locale by editing the URL. Cookie and header-based approaches require DevTools or specialized testing setups. -
Test with real devices in real regions.
A developer spoofing headers will miss edge cases that real users hit constantly: VPN interference, corporate proxies, shared computers, unusual browser locale configurations.
Locale detection done well is invisible infrastructure. Done poorly, it becomes one of the most persistent complaints in a multilingual product.
What comes next
Once you have settled on a locale detection strategy, the next challenge is managing the content for those URLs: translation files, missing keys, and keeping every language in sync with every release.
SimpleLocalize automates the translation side of that equation: push new keys from your CI/CD pipeline, auto-translate with DeepL, Google Translate, or any LLM, review in the online editor, and serve final translations from a global CDN without a rebuild. See how it fits into a continuous localization workflow, or start a free project to try it with your stack.
Related reading
- URLs in Localization: How to structure and optimize for multilingual websites
- Language vs Locale: What's the difference?
- The complete technical guide to Internationalization (i18n) & Software localization
- How to make a website multilingual: A developer's implementation guide
- Top language selector UX examples
- Continuous localization: What it is, why it matters, and how to implement it
- What is hreflang and how to use it
- Flags in language selectors: Why they may hurt UX in 2025
- How to handle pluralization across languages




