The locale resolution chain
How a request's single locale gets chosen from an ordered priority chain of signals, 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 product ships five locales: ['en-US', 'fr-FR', 'de-DE', 'es-ES', 'pt-PT']. Which language renders for each of them?
Every formatter you built in the previous lesson, Intl.NumberFormat, Intl.DateTimeFormat, and Intl.RelativeTimeFormat, took a locale argument as a given. This lesson answers the question those formatters quietly deferred: where does that one locale value come from on each request, and what makes the answer deterministic, the same every time for the same inputs? The answer is a single resolution chain: 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. That tag drives every formatter, every translation lookup, and the lang attribute on your <html> element. In the running stack, the next chapter wires next-intl to run this chain for you in middleware. Here you learn what it is actually doing.
The five signals a request carries
Section titled “The five signals a request carries”Before we talk about order, take inventory. An incoming request can carry up to five clues about which language a person wants. They are not equally trustworthy, and that inequality is what the rest of the lesson turns on. First, meet them.
- The URL prefix. A path like
/fr-FR/dashboardnames the locale right there in the address bar. It is explicit, it is shareable, and it survives being copied into a Slack 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 a 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 whatever the operating system or browser was set to, 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 signal of the five, and a later section argues at length that it should never sit at the top.
These five inputs collapse into exactly one output: a single locale tag. Before we rank them, hold that shape in your head: many signals in, one decision out.
A few terms before we go further. The ;q=0.9 you see in Accept-Language is a q-value . A tag like fr-FR is a BCP 47 tag. You met both in the previous lesson; the point to keep is that the region half carries real weight. And geo-IP is the act of guessing location from the network address.
Why the order is the whole game
Section titled “Why the order is the whole game”Here is the principle, stated before the mechanism: locale resolution is a priority chain, not a vote. You do not gather all five signals and pick the most popular. You ask them in a fixed order, top to bottom, and the first one that yields a locale your product actually supports wins. Resolution stops there, and nothing below it gets a say.
The ordering encodes one idea: a more explicit, more deliberate signal beats a less explicit one. Walk it rung by rung and the reasoning is concrete.
- URL prefix is first because it is the most explicit act anyone can perform. A link to
/de-DE/pricingmust render German no matter who clicks it, because the person who shared it already decided. Search crawlers request specific localized URLs and expect to get exactly that locale back, not a redirect. - Profile beats cookie and header because a signed-in user who chose
fr-FRin their settings meant it everywhere. That resolves our opening scenario’s first user: a French profile on a German hotel browser, where the profile wins and they read French. The browser’s language is noise here; the account is the truth. - Cookie beats the header because a switcher click is a deliberate override of the browser’s default, and it has to survive the next page load. If the header could overrule the cookie, every navigation would snap the anonymous visitor back to the language they were trying to leave.
- The header is a fallback hint, the best guess you have for a brand-new visitor who has given you no other signal.
- The default is the floor. There is always an answer, so resolution can never fall off the end with nothing.
The interactive below walks that chain the way the resolver does, one question at a time. Click through it and watch how the first satisfied rung ends the walk; the lower rungs are never reached.
This is 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.
This is 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.
This is the floor. There is always an answer, so resolution never falls off the end empty-handed.
Notice the shape of the walk. Each rung is a single yes-or-no question, and a “yes” ends the chain immediately. That is what priority means in code: not a scoring function, but an ordered series of early returns.
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 is the one with real algorithmic content, and it is where beginners reliably ship a bug, so let’s start with the bug.
Look at the two versions below. The first is what almost everyone writes the first time.
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. 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. The Portuguese speaker reads Portuguese. The fix isn’t more if statements; it’s the right matching algorithm doing what the naive one couldn’t.
The fix works because of a matching algorithm with a name. Let’s build the mental model in two layers: the idea first, then the 2026 implementation.
The idea: RFC 4647 Lookup
Section titled “The idea: RFC 4647 Lookup”The matching rules for language tags are defined by RFC 4647 , which describes two strategies. Lookup returns the single best match; Filter returns every match. For negotiation you want exactly one answer, so Lookup is the strategy that fits.
The Lookup procedure 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, progressively less specific. - If that still finds nothing, move to the next tag in the list and repeat.
- If everything is exhausted, return the default.
“Lookup” is that move from step 3: keep getting less specific until something matches. There is also a smarter, distance-based strategy called best fit. It knows, for instance, that Norwegian Bokmål is close to Norwegian, and it is region-aware. The match() function you’ll use actually defaults to best fit. But Lookup is the model worth understanding, because it is simple, deterministic, and explains every result you’ll see. Treat best fit as the better default that quietly does more than Lookup, never less.
Trace it through, 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 That subtag-stripping is the entire trick. Once you see pt-BR lose its region and find pt-PT waiting on the shared base, best-match stops being mysterious.
The 2026 implementation
Section titled “The 2026 implementation”You might hope for a native Intl.LocaleMatcher. It is coming, but as of mid-2026 it sits at TC39 Stage 1 , which means “the committee thinks this is worth pursuing,” not “you can use it.” So what you reach for today is @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 it 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, which means splitting on commas, reading q-values, and sorting, is a separate job. It is 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. Keep those two responsibilities distinct in your head: parse the header and 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 the project’s lib/i18n module, 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, not yours, so the lesson deliberately 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;};This is a typed seam: a raw string goes in, a validated Locale comes out. No caller downstream ever has to re-check the result against the supported set.
Now the framing that keeps this in proportion. In the running stack, next-intl’s middleware runs this exact chain for you, so you will rarely call match() by hand in app code. You reach for the standalone helper only where the middleware’s resolved locale isn’t available to you. That suggests a habit worth forming now: any Accept-Language read in a component or a page, anywhere that isn’t the middleware’s resolved value, 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, the profile, is not negotiated per request. For a signed-in user it is a durable column on the users table, the same way their timezone is. Let’s land that data model.
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'. This is the previous lesson’s “a locale is a contract” rule showing up in the schema: en-US and en-GB format dates and currency differently, so a language-only tag throws that distinction away before you’ve even started. 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 that 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 is what makes this useful: it 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 and no drift.
That same constant guards the write edge. When the profile form submits a new locale, it is validated against the supported set before it ever 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. This is the same Zod-at-the-boundary discipline you’ve applied to every other piece of user input; locale is no exception.
Where does the first value come from? It’s seeded at sign-up from the browser, using navigator.language or the value the chain just negotiated, captured in the same moment that the previous chapter captured users.timeZone. After that, it’s editable on the profile page through a <select>. One nicety borrowed from the previous lesson: 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. A French speaker scanning the list recognizes their language instantly.
Locale and timezone are independent
Section titled “Locale and timezone are independent”This pairing tempts a wrong instinct, so name it directly: locale and timezone are two separate axes, and neither implies the other. It is 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 regardless of where 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, and no inference between them, ever.
- 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 covers most of your marketing traffic, there is no profile rung. The chain collapses to URL prefix, then cookie, then Accept-Language, then default. That cookie is doing real work, and it’s worth understanding why it has to exist.
Picture it. An anonymous visitor on your marketing site clicks the switcher to French. Their browser still sends en in Accept-Language on the very next request, because clicking a button in your UI doesn’t change the browser’s settings. Without somewhere to persist that choice, the next page load would consult the header, see en, and snap the visitor straight back to English. The NEXT_LOCALE cookie is what remembers the override across navigations and return visits. It is the anonymous visitor’s equivalent of users.locale.
next-intl 4 made the cookie’s behavior more conservative, and it’s worth stating precisely because it’s a recent change. 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. In other words, the cookie is written 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 entirely, but those are configuration knobs for the next chapter. What matters now is that your mental model of when the cookie appears is correct.
There’s a privacy reason for that design. A locale cookie is “strictly necessary”: it is what makes the site work in the language the visitor chose, not a tracking device. So it sits outside consent-gating, on the right side of the line drawn by the security and consent baseline from earlier in the course. The session-by-default, write-only-on-override design is exactly what keeps it there, because it stores the minimum and only when the user actively asked for something.
The locale switcher and <html lang>
Section titled “The locale switcher and <html lang>”The chain has two visible surfaces in the product: the switcher that feeds it a choice, and the <html lang> attribute that broadcasts its result.
The switcher is a dropdown in your layout, and on selection it does three things at once: 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 part is the one people get 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 that prefix rewrite for you (wired in the next chapter); the intent looks like this:
const onSelectLocale = (nextLocale: Locale) => { setLocaleCookie(nextLocale); if (isAuthenticated) { updateProfileLocale(nextLocale); } // Same path, new locale prefix — preserve the deep route. router.replace(pathname, { locale: nextLocale });};Options display in their own language, the Intl.DisplayNames touch from before: English, Français, and Deutsch.
The result of the whole chain surfaces in one attribute on the root layout:
<html lang={locale}><html lang> is small and load-bearing. Screen readers pick their voice and pronunciation from it. Browsers choose hyphenation rules and spell-check dictionaries from it. Search engines and machine-translation tools key on it. Set it wrong and a French page gets read aloud in an English accent.
There is one bug here worth remembering. Render lang from the chain’s resolved value, never from the raw cookie or from navigator.language. The server renders the HTML before the browser ever touches it, so if the server computes the locale one way and the client computes it another, the two 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 server and client agree by construction. (For RTL languages you’d pair this with a dir attribute; that’s beyond this chapter, named once.)
One last cheap win: mirror the resolved locale into the response with a Content-Language header, as in Content-Language: fr-FR. Crawlers and proxies read it, and it costs you one line in the middleware.
Accept-Language is one signal, not the truth
Section titled “Accept-Language is one signal, not the truth”Step back and harden the model, because the header rung is where overconfidence creeps in. Accept-Language carries ranked BCP 47 tags with q-values, and that is all it carries. It does not carry a timezone. It does not carry a currency preference, a calendar system, or a region of residence. The en-US arriving from a German national on vacation is real, valid data, not an error to correct. That is precisely why the header is rung 4: a hint, never the truth.
Which brings us to geo-IP, and why the opening inventory dimmed it. The instinct is tempting: the request comes from a French IP, so default to French. Never make geo-IP a primary signal. It breaks two entirely ordinary people:
- The French speaker working a contract in Brazil. Geo-IP says Brazil and serves Portuguese, so a Francophone is now reading a language they may not know, on a product they’re paying for.
- The English-speaking expat living in Tokyo. Geo-IP says Japan and serves Japanese, to someone who wanted English the whole time.
IP tells you where the network 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 exactly why the chapter’s chain leaves it out of the primary five. And auto-redirecting anonymous visitors by IP is worse than merely guessing wrong: it breaks deep links and confuses search crawlers. A crawler running from a US datacenter that requests /fr-FR/pricing must get French back; if you redirect it to /en-US/, the French page never gets indexed.
A few remaining failure modes, each tied to its rung:
- Storing a bare
'en'instead of full BCP 47 (rung 2) silently breaks region-specific formatting, and the user wonders why their dates look American. - Updating
users.localewithout also writing the cookie leaves anonymous navigation stuck in the old locale until the next sign-in, so the change appears not to take. - Rendering
<html lang>from the cookie instead of the resolved value is the hydration-mismatch bug again: server and client disagree, and the page flashes.
Check your understanding
Section titled “Check your understanding”The whole lesson lives in the order. Start by reconstructing it: a request arrives carrying every possible signal, so put them in the sequence the resolver checks them, from the one it consults first to the last-resort fallback.
A request arrives carrying every signal. Drag them into the order the resolver checks them — from the one consulted first to the last-resort fallback. Drag the items into the correct order, then press Check.
/fr-FR/...) users.locale) NEXT_LOCALE) Accept-Language best-match Now apply that order, together with the best-match rule, to three concrete requests. Each blank is one resolution; walk the chain for each.
Walk the chain for each request and pick the locale it resolves to. Pick the right option from each dropdown, then press Check.
Start with the brand-new visitor. 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.
Now climb to the top of the chain. A signed-in user whose users.locale is 'fr-FR' opens a shared link to /de-DE/billing. The profile would say 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.
Finally, the override that has to persist. An anonymous visitor whose Accept-Language is en-US clicked the switcher to German on a previous page, so a NEXT_LOCALE=de-DE cookie is set. 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 felt surprising, that’s the one to hold on to: a shared localized link is the most explicit request anyone can make, and explicitness wins even over a signed-in user’s saved preference, because for the length of that one link the user asked for something specific.
External resources
Section titled “External resources”The chain above is the concept; the next chapter 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 chapter: the middleware that resolves the locale for you.
The header reference — q-values, syntax, and what browsers actually send.