Skip to content
Chapter 84Lesson 4

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.

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.

  1. The URL prefix. A path like /fr-FR/dashboard names 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.
  2. The profile column. users.locale, a value an authenticated user set on their settings page. A deliberate, durable preference attached to an account.
  3. 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.
  4. The Accept-Language header. 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.
  5. Geo-IP. The approximate country an IP address maps to, exposed as request.geo.country on Vercel. It is the weakest of the five.
URL prefix /fr-FR/dashboard
Profile users.locale
Accept-Language browser hint
Geo-IP weak / last resort
resolve() runs once per request
locale e.g. 'fr-FR'
Five signals enter resolve(); one validated locale leaves.

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.

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/pricing must 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-FR in 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.

Resolving the request locale

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.

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:

  1. Parse the header into its tags, ordered by q-value, most-preferred first. pt-BR,pt;q=0.9 becomes ['pt-BR', 'pt'].
  2. Take the first tag and try it exactly against the supported set. If it hits, you’re done.
  3. If it misses, strip the trailing subtag (de-DE becomes de) and try again, less specific each pass.
  4. If that still finds nothing, move to the next tag in the list and repeat.
  5. 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'].

Accept-Language
pt-BR,pt;q=0.9
pt-BR pt
Supported set
pt-PT en-US
Status
Parsed Header → ranked tag list. Nothing tried yet.
Parse the header into a ranked tag list, most-preferred first.
Accept-Language
pt-BR,pt;q=0.9
pt-BR pt
Supported set
pt-PT en-US
Status
Miss No 'pt-BR' in the supported set.
Try the first tag exactly. No 'pt-BR' in the supported set — miss.
Accept-Language
pt-BR,pt;q=0.9
pt-BR pt
Supported set
pt-PT en-US
Status
Match Stripped to base 'pt' — pt-PT shares it.
Strip the region subtag: pt-BR becomes pt. Now pt-PT shares the 'pt' base — match.
Accept-Language
pt-BR,pt;q=0.9
pt-BR pt
Supported set
pt-PT en-US
Status
Resolved → pt-PT
Resolved: pt-PT. The Portuguese speaker reads Portuguese, not the English fallback.

Watch pt-BR lose its region and find pt-PT waiting on the shared base, and best-match stops being mysterious.

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.

1 / 1

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 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.

How it reads locale
Column
users.locale
Picker
a language <select> on the profile page
Drives
Intl.* formatters, t() lookups, <html lang>
Example
en-GB
When it happens timezone
Column
users.timeZone
Picker
a timezone <select> on the profile page
Drives
date/time rendering (timeZone option), scheduling
Example
Europe/Berlin
The same Berlin operator reads in en-GB and operates in Europe/Berlin: two independent columns, each set by its own picker.

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 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.

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.

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.