Choose the first language
/ is the one URL without a locale, and whoever opens it is sent on to the page in their language. This page covers negotiate, which picks one locale from what the visitor asks for, and how / is written under @k8ordo/server and under @k8ordo/static.
On this page
Choose from a list of preferences
locales.negotiate picks one locale of the set from the visitor’s list of preferences. In the browser, navigator.languages goes in as it is.
locales.all; // ['ja', 'en', 'en-GB']
locales.negotiate(navigator.languages);
locales.negotiate(['en-GB']); // 'en-GB'
locales.negotiate(['en-US', 'ja']); // 'en'
locales.negotiate(['ja-JP', 'en']); // 'ja'
locales.negotiate(['***', 'en']); // 'en'
locales.negotiate(['fr', 'de']); // 'ja'- Take the requested tags one at a time, from the first.
- If the set has that very tag, return it. Case does not matter, so a request for
en-gbgetsen-GB, spelled as the set spells it. - Otherwise, return the locale of the same language that comes first in the set.
- If neither exists, move on to the next tag. A tag that is not BCP 47 is skipped rather than thrown on, because the list is what the visitor sent.
- If nothing matched by the end, return the default locale.
The whole list is not searched for an exact tag first, so that the visitor’s first choice wins. Someone who sends ['en-US', 'ja'] wants English most, so if the set has en they get en, even though ja further down is in the set exactly.
Note
Languages are compared by Intl.Locale’s language alone; script and region are not weighed. With zh-Hans and zh-Hant in the set, a request for zh-Hant-TW gets zh-Hans, the one written first.
Try negotiation
Edit the Accept-Language header and watch the list parseAcceptLanguage makes, what became of each tag, and the answer negotiate gives. The set is this site’s own locales (ja and en, default ja).
parseAcceptLanguage returnsfr-CHno matchfrno matchen-USsame language → enjanot looked at (already settled)
negotiate returnsen
Try it
- With the header already there,
fr-CHandfrmatch no locale, anden-USmatchesenby language. The answer isen, and thejaafter it is never looked at. - Press
en-US, ja.jais in the set exactly, but the answer is settled whenen-USbefore it matchesenby language. - Press
en_US, ja;q=0.5.en_USis not BCP 47 and is skipped, so the answer isja. - Press
*;q=0.5, en;q=0, de.*anden, weighted 0, drop out of the list, anddematches nothing, so the answer is the default,ja. - Press “This browser’s navigator.languages” to negotiate again from this browser’s language settings.
Read Accept-Language
On a server, the request’s Accept-Language header is what the visitor asks for. parseAcceptLanguage turns it into a list negotiate takes.
import { parseAcceptLanguage } from '@k8ordo/i18n';
parseAcceptLanguage('en-US;q=0.8, ja, en;q=0.9');
// ['ja', 'en', 'en-US']- Tags are ordered by their
qweight, highest first. A tag without one weighs 1. - Tags of equal weight keep the header’s order.
- Tags weighted 0 or less, and
*, are dropped.*means “anything”, which is what the default locale already means. - A
qthat is not a number is ignored, and the tag keeps a weight of 1. - A missing header (
null) or a blank one gives an empty array.
The tags themselves are not checked; negotiate skips the ones that are not BCP 47.
Choose from a request
negotiateRequest picks a locale for a Request. Give it a cookie name, and the language the visitor chose before is read from that cookie first, then Accept-Language.
locales.negotiateRequest(request, { cookie: 'locale' });The cookie’s value and the header’s list go through the same negotiate, so a cookie still holding a locale the set no longer has falls through to the header. Without cookie, only the header is read.
Writing the cookie is covered in “Switch languages”.
Answer / under @k8ordo/server
Under @k8ordo/server, a guard.ts answers / before any page renders. It picks a locale with negotiateRequest and returns a 307 to that locale’s URL.
routes/(home)/guard.tsimport { withBase } from '@k8ordo/router';
import type { Guard } from '@k8ordo/server/runtime';
import { locales } from '../../i18n';
const guard: Guard<'/'> = ({ request }) => {
const locale = locales.negotiateRequest(request, {
cookie: 'locale',
});
return new Response(null, {
status: 307,
headers: { location: withBase(locales.localize('/', locale)) },
});
};
export default guard;routes/(home)/page.tsxexport default function RootPage() {
return null;
}The files sit like this.
routes/
layout.tsx
(home)/
page.tsx
guard.ts
[locale]/
layout.tsx- Put the guard and the page in a route group such as
(home)/. Aguard.tsruns before every URL below its directory, so one placed directly inroutes/would send/en/…away too. - The page never renders, but it has to exist: a guard runs only before a URL a page declares, and without one
/is a 404. - It is
307, not308, because the answer depends on who asks.localizeleaves Vite’sbaseout, sowithBaseputs it back. - A visitor without JavaScript is sent on all the same, and so is a client navigation to
/: the guard also answers the payload request the browser makes for it.
Answer / under @k8ordo/static
@k8ordo/static has no server to answer a request, and refuses a guard.ts. So the / page renders nothing, and negotiates and moves on in the browser.
routes/page.tsx'use client';
import { useEffect } from 'react';
import { locales } from '../i18n';
import { navigateTo } from '../links';
export default function RootRedirect() {
useEffect(() => {
navigateTo(
'/:locale',
{ locale: locales.negotiate(navigator.languages) },
{ history: 'replace' },
);
}, []);
return null;
}navigateTo is the one made with @k8ordo/router’s bindParams; “Use with other packages” shows how.
- It moves from an effect. The static build renders this page outside a browser too, where there is no Navigation API; and a
navigator.languagesthere, if any, belongs to the machine running the build, not the visitor. history: 'replace'keeps the back button from returning to/only to be sent on again.- Without
@k8ordo/router,location.replace(locales.localize('/', locale))does the same job.
Note
A visitor without JavaScript stays on /.
If the chosen language is kept in a cookie, put its value in front of navigator.languages and hand both to negotiate: the same order negotiateRequest reads them in on a server.
routes/page.tsxuseEffect(() => {
void cookieStore.get('locale').then((saved) => {
navigateTo(
'/:locale',
{
locale: locales.negotiate([
...(saved?.value === undefined ? [] : [saved.value]),
...navigator.languages,
]),
},
{ history: 'replace' },
);
});
}, []);