@k8ordo/i18n

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.

ts
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'
  1. Take the requested tags one at a time, from the first.
  2. If the set has that very tag, return it. Case does not matter, so a request for en-gb gets en-GB, spelled as the set spells it.
  3. Otherwise, return the locale of the same language that comes first in the set.
  4. 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.
  5. 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.

Information

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.

Playground

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

Examples
The list parseAcceptLanguage returns
  1. fr-CHno match
  2. frno match
  3. en-USsame language → en
  4. janot looked at (already settled)
What negotiate returns

en

Try it

  1. With the header already there, fr-CH and fr match no locale, and en-US matches en by language. The answer is en, and the ja after it is never looked at.
  2. Press en-US, ja. ja is in the set exactly, but the answer is settled when en-US before it matches en by language.
  3. Press en_US, ja;q=0.5. en_US is not BCP 47 and is skipped, so the answer is ja.
  4. Press *;q=0.5, en;q=0, de. * and en, weighted 0, drop out of the list, and de matches nothing, so the answer is the default, ja.
  5. 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.

ts
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 q weight, 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 q that 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.

ts
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.ts
import { 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.tsx
export default function RootPage() {
  return null;
}

The files sit like this.

text
routes/
  layout.tsx
  (home)/
    page.tsx
    guard.ts
  [locale]/
    layout.tsx
  • Put the guard and the page in a route group such as (home)/. A guard.ts runs before every URL below its directory, so one placed directly in routes/ 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, not 308, because the answer depends on who asks. localize leaves Vite’s base out, so withBase puts 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.languages there, 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.
Information

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.tsx
useEffect(() => {
  void cookieStore.get('locale').then((saved) => {
    navigateTo(
      '/:locale',
      {
        locale: locales.negotiate([
          ...(saved?.value === undefined ? [] : [saved.value]),
          ...navigator.languages,
        ]),
      },
      { history: 'replace' },
    );
  });
}, []);
k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2