Locale detection strategies: URL, Cookie, or Header?

Kinga Pomykała
Kinga Pomykała
Last updated: March 30, 202624 min read
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

StrategySEOCachingShareabilityFirst-visit UXSearch intentComplexity
URL pathExcellentSimplePerfectRequires redirectHigh (indexed)Low
SubdomainGoodGoodGoodRequires redirectHigh (indexed)High
CookieNoneComplexNoneImmediateNone (app-state)Medium
Accept-LanguageNoneVary headerNoneAutomaticNone (app-state)Low
IP geolocationNoneSimpleNoneInaccurateNone (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/pricing and /de/pricing separately, 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/pricing and /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/pricing and 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:

StrategyDefault locale URLNon-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.


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.language and 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: Cookie in 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 to Vary: Accept-Language for 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-Language CDN trap.
If you serve different content per Accept-Language value without encoding the locale in the URL, you must set Vary: Accept-Language on 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 need Vary: 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-Language header 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, Singapore
  • zh-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
  • For most web applications and SaaS marketing sites:
    URL path segments as the primary locale signal. Detect locale on first visit from the Accept-Language header 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, then Accept-Language header, 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:
    Read Locale.current (iOS/macOS) or Locale.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.


Kinga Pomykała
Kinga Pomykała
Content creator of SimpleLocalize

Get started with SimpleLocalize

  • All-in-one localization platform
  • Web-based translation editor for your team
  • Auto-translation, QA-checks, AI and more
  • See how easily you can start localizing your product.
  • Powerful API, hosting, integrations and developer tools
  • Unmatched customer support
Start for free
No credit card required5-minute setup
"The product
and support
are fantastic."
Laars Buur|CTO
"The support is
blazing fast,
thank you Jakub!"
Stefan|Developer
"Interface that
makes any dev
feel at home!"
Dario De Cianni|CTO
"Excellent app,
saves my time
and money"
Dmitry Melnik|Developer