The locale resolution chain
How a request's single locale is chosen from an ordered priority chain, URL prefix, profile, cookie, Accept-Language best-match, then a default.
A French user, whose users.locale is 'fr-FR', signs in from a hotel in Berlin. The hotel’s machine has a German browser, so the request arrives with Accept-Language: de-DE,de;q=0.9,en;q=0.7. At the same time, an anonymous visitor lands on your marketing page from São Paulo and sends Accept-Language: pt-BR,pt;q=0.9. Your app ships five locales: ['en-US', 'fr-FR', 'de-DE', 'es-ES', 'pt-PT']. Which language renders for each of them?
Every formatter from the previous lesson, Intl.NumberFormat, Intl.DateTimeFormat, and Intl.RelativeTimeFormat, took its locale as a given. Where does that one value come from on each request? A single resolution chain decides it: URL prefix, then profile, then cookie, then Accept-Language best-match, then a default. The chain runs once per request and lands one validated tag, and that tag drives every formatter, every translation lookup, and the lang attribute on your <html> element. The next lesson has next-intl run this chain for you; here you learn what it does.
The five locale signals a request carries
Section titled “The five locale signals a request carries”An incoming request can carry up to five clues about which language a person wants, and they are not equally trustworthy. That inequality is what the ranking later in this lesson rests on.
- The URL prefix. A path like
/fr-FR/dashboardnames the locale right in the address bar. It is explicit and shareable: it survives being copied into a chat message or crawled by a search bot. - The profile column.
users.locale, a value an authenticated user set on their settings page. A deliberate, durable preference attached to an account. - The cookie.
NEXT_LOCALE, written when someone clicks your language switcher. It remembers that choice across page loads for a visitor who hasn’t signed in. - The
Accept-Languageheader. The browser’s ranked list of languages, sent on every request. It is a hint, not a decision: it reflects how the operating system or browser was set, which is not always what this person wants on your site. - Geo-IP. The approximate country an IP address maps to, exposed as
request.geo.countryon Vercel. It is the weakest of the five.
Three terms before we rank these. The ;q=0.9 in Accept-Language is a q-value . A tag like fr-FR is a BCP 47 tag, and its region half carries real weight. Geo-IP is guessing location from the network address.
A priority chain, not a vote
Section titled “A priority chain, not a vote”Locale resolution is a priority chain, not a vote. You do not gather all five signals and pick the most popular; you check them in a fixed order and the first one that yields a locale your product supports wins. Resolution stops there, and nothing below it gets a say.
The order encodes one idea: a more explicit, more deliberate signal beats a less explicit one.
- URL prefix is first because it is the most explicit act anyone can perform. A link to
/de-DE/pricingmust render German for whoever clicks it, and search crawlers expect the exact locale they requested, not a redirect. - Profile beats cookie and header because a signed-in user who chose
fr-FRin their settings meant it everywhere. The browser’s language is noise; the account is the truth. - Cookie beats header because a switcher click is a deliberate override that has to survive the next page load. If the header could overrule it, every navigation would snap the anonymous visitor back to the language they were leaving.
- The header is a fallback hint, the best guess for a brand-new visitor who has given you no other signal.
- The default is the floor, so resolution always has an answer and never falls off the end empty-handed.
The interactive below walks the chain one question at a time, the way the resolver does. Click through and watch the first satisfied rung end the walk.
The most explicit signal. A shared /de-DE/... link renders German for everyone, and SEO crawlers get exactly the locale they asked for.
A signed-in user’s deliberate setting beats whatever browser they happen to be on, so the French user in the Berlin hotel reads French.
A switcher click is a deliberate override, and the cookie carries it across page loads for anonymous visitors.
The best guess for a brand-new visitor with no other signal. It is a best-match, not exact equality; the next section shows why that distinction matters.
The floor. There is always an answer, so resolution never falls off the end empty-handed.
Best-matching Accept-Language against the supported set
Section titled “Best-matching Accept-Language against the supported set”Four of the five rungs are simple lookups: read a value, check it against your supported set, done. The header rung carries the real algorithmic content, and it is where beginners reliably ship a bug. Here is that bug beside its fix; the first version is what almost everyone writes first.
const SUPPORTED_LOCALES = ['pt-PT', 'en-US'];const DEFAULT_LOCALE = 'en-US';
const resolveFromHeader = (requestedLocales: string[]) => { // requestedLocales is e.g. ['pt-BR', 'pt'] const exactMatch = requestedLocales.find((tag) => SUPPORTED_LOCALES.includes(tag), ); return exactMatch ?? DEFAULT_LOCALE;};The canonical i18n negotiation bug. Our São Paulo visitor’s tags are ['pt-BR', 'pt']. Neither is in the supported set, so find returns undefined and the function falls through to the English default. A Portuguese speaker gets served English even though you ship Portuguese, because exact-equality matching can’t see that pt-BR and pt-PT are the same language.
import { match } from '@formatjs/intl-localematcher';
const SUPPORTED_LOCALES = ['pt-PT', 'en-US'];const DEFAULT_LOCALE = 'en-US';
const resolveFromHeader = (requestedLocales: string[]) => { // requestedLocales is e.g. ['pt-BR', 'pt'] return match(requestedLocales, SUPPORTED_LOCALES, DEFAULT_LOCALE);};Best-match, not exact-match. match() strips the region off pt-BR, sees the base language pt, and recognizes that pt-PT shares it, so it returns pt-PT and the Portuguese speaker reads Portuguese. The fix isn’t more if statements; it’s the right matching algorithm.
The idea: RFC 4647 Lookup
Section titled “The idea: RFC 4647 Lookup”The matching rules for language tags come from RFC 4647 , which offers two strategies: Lookup returns the single best match, Filter returns every match. Negotiation needs exactly one answer, so Lookup is the fit.
Lookup reads like a person scanning a list:
- Parse the header into its tags, ordered by q-value, most-preferred first.
pt-BR,pt;q=0.9becomes['pt-BR', 'pt']. - Take the first tag and try it exactly against the supported set. If it hits, you’re done.
- If it misses, strip the trailing subtag (
de-DEbecomesde) and try again, less specific each pass. - If that still finds nothing, move to the next tag in the list and repeat.
- If everything is exhausted, return the default.
Step 3 is the whole move: get less specific until something matches. There is also a distance-based strategy, best fit, which is region-aware and knows, for instance, that Norwegian Bokmål is close to Norwegian; match() defaults to it. Understand Lookup anyway, because it is simple, deterministic, and explains every result you’ll see, and best fit only ever does more than Lookup, never less.
Trace it one step at a time for our pt-BR visitor against the supported set ['pt-PT', 'en-US'].
pt-BR,pt;q=0.9 pt-PT en-US pt-BR,pt;q=0.9 pt-PT en-US pt-BR,pt;q=0.9 pt-PT en-US pt-BR,pt;q=0.9 pt-PT en-US Watch pt-BR lose its region and find pt-PT waiting on the shared base, and best-match stops being mysterious.
The 2026 implementation
Section titled “The 2026 implementation”A native Intl.LocaleMatcher is coming, but as of mid-2026 it sits at TC39 Stage 1 , meaning the committee thinks it worth pursuing, not that you can ship it. So today you reach for @formatjs/intl-localematcher, a ponyfill for that exact proposal, used across the i18n ecosystem (next-intl included). Its signature:
match(requestedLocales, availableLocales, defaultLocale, options?);It takes the requested locales, your supported set, and the default to fall back to, and returns one tag.
One detail you must not miss: match() does not parse Accept-Language for you. It expects an already-parsed array of tags. Turning the raw header into that array, splitting on commas, reading q-values, and sorting, is a separate job, usually handled by a small companion like the negotiator package, the pairing the Next.js i18n guide uses, or by a few lines you write yourself. Parse the header first, then match the tags.
Here is the standalone helper, the form you would write outside the middleware: in a Server Action, a CLI script, or a route handler that needs to negotiate without the middleware’s already-resolved value.
import { match } from '@formatjs/intl-localematcher';import Negotiator from 'negotiator';import { SUPPORTED_LOCALES, DEFAULT_LOCALE, type Locale } from '@/lib/i18n';
export const negotiateLocale = (acceptLanguage: string): Locale => { // `negotiator` parses the raw header into ranked tags for us. const requested = new Negotiator({ headers: { 'accept-language': acceptLanguage }, }).languages(); return match(requested, [...SUPPORTED_LOCALES], DEFAULT_LOCALE) as Locale;};Pull match from the ponyfill, Negotiator for header parsing, and the supported set, default, and Locale type from lib/i18n, the single place that owns which locales exist.
import { match } from '@formatjs/intl-localematcher';import Negotiator from 'negotiator';import { SUPPORTED_LOCALES, DEFAULT_LOCALE, type Locale } from '@/lib/i18n';
export const negotiateLocale = (acceptLanguage: string): Locale => { // `negotiator` parses the raw header into ranked tags for us. const requested = new Negotiator({ headers: { 'accept-language': acceptLanguage }, }).languages(); return match(requested, [...SUPPORTED_LOCALES], DEFAULT_LOCALE) as Locale;};Let negotiator turn the raw header into a ranked tag list, highest q-value first. Header grammar is its job, so the lesson doesn’t re-implement it.
import { match } from '@formatjs/intl-localematcher';import Negotiator from 'negotiator';import { SUPPORTED_LOCALES, DEFAULT_LOCALE, type Locale } from '@/lib/i18n';
export const negotiateLocale = (acceptLanguage: string): Locale => { // `negotiator` parses the raw header into ranked tags for us. const requested = new Negotiator({ headers: { 'accept-language': acceptLanguage }, }).languages(); return match(requested, [...SUPPORTED_LOCALES], DEFAULT_LOCALE) as Locale;};Hand the parsed tags, the supported set (spread into a fresh array, since match takes a mutable string[]), and the default to match(). It returns exactly one supported tag.
import { match } from '@formatjs/intl-localematcher';import Negotiator from 'negotiator';import { SUPPORTED_LOCALES, DEFAULT_LOCALE, type Locale } from '@/lib/i18n';
export const negotiateLocale = (acceptLanguage: string): Locale => { // `negotiator` parses the raw header into ranked tags for us. const requested = new Negotiator({ headers: { 'accept-language': acceptLanguage }, }).languages(); return match(requested, [...SUPPORTED_LOCALES], DEFAULT_LOCALE) as Locale;};A typed seam: a raw string goes in, a validated Locale comes out, so no downstream caller has to re-check the result against the supported set.
In the running stack, next-intl’s middleware runs this chain for you, so you’ll rarely call match() by hand. Reach for the standalone helper only where the middleware’s resolved locale isn’t available. Any Accept-Language read outside that one place is a sign something is wrong: the header gets read once, at the edge, and never again.
users.locale: the profile column, paired with users.timeZone
Section titled “users.locale: the profile column, paired with users.timeZone”Rung 2 of the chain is the profile, which isn’t negotiated per request. For a signed-in user it’s a durable column on the users table, like their timezone.
The column is plain text, not null, defaulting to your source locale:
locale: text('locale').notNull().default('en-US'),Store the full BCP 47 tag, 'en-US', never bare 'en'. A locale is a contract: en-US and en-GB format dates and currency differently, so a language-only tag throws that distinction away. A bare 'en' in this column is a smell.
What counts as “supported” lives in one place, a constant near your routing config in lib/i18n.ts, so every other part of the system reads from the same source of truth.
export const SUPPORTED_LOCALES = ['en-US', 'fr-FR', 'de-DE', 'es-ES', 'pt-PT'] as const;export const DEFAULT_LOCALE = 'en-US';
export type Locale = (typeof SUPPORTED_LOCALES)[number];The as const freezes the array into a tuple of literal types, and (typeof SUPPORTED_LOCALES)[number] derives the union 'en-US' | 'fr-FR' | 'de-DE' | 'es-ES' | 'pt-PT' from it. Add a locale to the array and the Locale type updates with it: one edit, no drift.
That same constant guards the write edge. When the profile form submits a new locale, it’s validated against the supported set before it reaches the column:
const updateProfileSchema = z.object({ locale: z.enum(SUPPORTED_LOCALES), timeZone: z.string(),});z.enum(SUPPORTED_LOCALES) rejects anything that isn’t one of the five supported tags, so an unsupported or malformed locale can never be written, the same Zod-at-the-boundary discipline you apply to every other piece of user input.
The first value is seeded at sign-up from the browser, using navigator.language or the value the chain just negotiated, in the same moment the previous chapter captured users.timeZone. After that it’s editable on the profile page through a <select>. Render each option’s name in its own language with Intl.DisplayNames, so the menu reads Français and Deutsch rather than French and German, and a French speaker recognizes their language instantly.
Locale and timezone are independent
Section titled “Locale and timezone are independent”Locale and timezone are two separate axes, and neither implies the other. It’s easy to assume de-DE means Europe/Berlin. It does not.
A Berlin-based operator might read your app in en-GB while operating in Europe/Berlin, because plenty of professionals work in English wherever they live. A San Francisco user might read in es-ES while operating in America/Los_Angeles. Two columns, two pickers on the settings page, no inference between them.
- Column
- users.locale
- Picker
- a language <select> on the profile page
- Drives
- Intl.* formatters, t() lookups, <html lang>
- Example
- en-GB
- Column
- users.timeZone
- Picker
- a timezone <select> on the profile page
- Drives
- date/time rendering (timeZone option), scheduling
- Example
- Europe/Berlin
Anonymous visitors and the locale cookie
Section titled “Anonymous visitors and the locale cookie”For a visitor who hasn’t signed in, which is most of your marketing traffic, there is no profile rung. The chain collapses to URL prefix, then cookie, then Accept-Language, then default.
Consider why that cookie has to exist. An anonymous visitor clicks the switcher to French, but their browser still sends en in Accept-Language on the next request, because clicking a button in your UI doesn’t change the browser’s settings. Without somewhere to persist the choice, the next page load would consult the header, see en, and snap the visitor back to English. The NEXT_LOCALE cookie remembers the override across navigations and return visits. It is the anonymous visitor’s equivalent of users.locale.
In next-intl 4 the locale cookie defaults to a session cookie , gone when the browser closes, and it is written only when the visitor picks a locale that differs from their Accept-Language, that is, only on a genuine override. A cookie that merely echoed the header would carry no information, so it isn’t set at all. You can lengthen its life with a maxAge or switch it off, both covered next lesson.
This is also what keeps the cookie consent-exempt. A locale cookie is strictly necessary: it makes the site work in the language the visitor chose, rather than tracking them, so it sits outside consent-gating, on the right side of the line drawn by the earlier security and consent baseline. Storing the minimum, and only when the user actively asked for it, is what keeps it there.
The locale switcher and <html lang>
Section titled “The locale switcher and <html lang>”The chain has two visible surfaces: the switcher that feeds it a choice, and the <html lang> attribute that carries its result.
The switcher is a dropdown in your layout, and on selection it does three things: it writes the NEXT_LOCALE cookie, it calls a Server Action to update users.locale if the visitor is authenticated, and it navigates to the same path under the new locale prefix. That last step is where people get it wrong. Switching language on /fr-FR/billing/123 should land on /de-DE/billing/123, the same invoice in German, not bounce the user back to the home page. next-intl’s usePathname and useRouter handle the prefix rewrite for you:
const onSelectLocale = (nextLocale: Locale) => { setLocaleCookie(nextLocale); if (isAuthenticated) { updateProfileLocale(nextLocale); } // Same path, new locale prefix — preserve the deep route. router.replace(pathname, { locale: nextLocale });};Each option displays in its own language through Intl.DisplayNames: English, Français, Deutsch.
The chain’s result surfaces in one attribute on the root layout:
<html lang={locale}>This attribute is small and load-bearing: screen readers pick their voice and pronunciation from it, browsers choose hyphenation rules and spell-check dictionaries, and search engines and machine translation key on it. Set it wrong and a French page gets read aloud in an English accent.
Render lang from the chain’s resolved value, never from the raw cookie or navigator.language. The server renders the HTML before the browser touches it, so if server and client compute the locale differently, they disagree on the first paint and React throws a hydration mismatch : a visible flash of the wrong language before it corrects. Drive lang from the single resolved locale and the two agree by construction. (RTL languages would pair this with a dir attribute, beyond this chapter.)
Finally, mirror the resolved locale into the response with a Content-Language header, such as Content-Language: fr-FR. Crawlers and proxies read it, and it costs one line in the middleware.
Accept-Language is one signal, not the truth
Section titled “Accept-Language is one signal, not the truth”Accept-Language carries ranked BCP 47 tags with q-values, and that is all it carries: no timezone, no currency, no calendar system, no region of residence. The en-US from a German national on vacation is real, valid data, not an error to correct. That is why the header is rung 4, a hint and never the truth.
Geo-IP tempts the same overconfidence: the request comes from a French IP, so default to French. Never make geo-IP a primary signal. It breaks two ordinary people:
- The French speaker on a contract in Brazil. Geo-IP serves Portuguese, so a Francophone is reading a language they may not know, on a product they pay for.
- The English-speaking expat in Tokyo. Geo-IP serves Japanese to someone who wanted English all along.
An IP tells you where the packet originated, not what language a human reads. At best it’s a weak last-resort tiebreaker, never something that overrides an explicit signal, which is why the chain leaves it out of the primary five. Auto-redirecting anonymous visitors by IP is worse than guessing wrong: it breaks deep links and confuses crawlers. A crawler in a US datacenter that requests /fr-FR/pricing must get French back; redirect it to /en-US/ and the French page never gets indexed.
Check your understanding
Section titled “Check your understanding”A request can arrive carrying every signal at once. The resolver checks them in a fixed order, stopping at the first one present. Run that order against three requests.
Walk the chain for each request and pick the locale it resolves to. Pick the right option from each dropdown, then press Check.
An anonymous visitor with no cookie sends Accept-Language: pt-BR,pt;q=0.9; the supported set is ['pt-PT', 'en-US']. With no URL prefix, no profile, and no cookie, resolution falls to the header, so they get , because best-match strips the region off pt-BR and lands on the shared pt base.
A signed-in user whose users.locale is 'fr-FR' opens a shared link to /de-DE/billing. The profile says French, but a rung sits above it, so they get , because the URL prefix is the most explicit signal and trumps even a saved preference.
An anonymous visitor whose Accept-Language is en-US clicked the switcher to German on a previous page, setting a NEXT_LOCALE=de-DE cookie. With no URL prefix, they get , because the cookie carries that deliberate switch and beats the browser’s header hint.
If the URL-beats-profile case surprised you, hold onto it: a shared localized link is the most explicit request anyone can make, and explicitness wins even over a signed-in user’s saved preference.
External resources
Section titled “External resources”The chain above is the concept; the next lesson wires it into next-intl’s middleware. These point at the primary sources and at where that wiring is headed.
The match() ponyfill used across the i18n ecosystem — the standalone form this lesson built on.
The Stage 1 proposal the ponyfill anticipates — the native future of locale negotiation.
Where this chain runs in the next lesson: the middleware that resolves the locale for you.
The header reference — q-values, syntax, and what browsers actually send.