@k8ordo/i18n

Switch languages

Switching language means opening the same pathname again under another locale’s segment. This page builds the destination with localize and delocalize, puts a language switcher together, and remembers the choice in a cookie.

On this page

Build the destination

Take the locale segment off the current pathname with delocalize, and put another locale’s on with localize.

ts
locales.delocalize('/ja/products/42');
// { locale: 'ja', pathname: '/products/42' }

locales.localize('/products/42', 'en');
// '/en/products/42'
  • delocalize works by segment. The first segment of /english is english, which is no locale, so it returns locale: null.
  • When the first segment is not a locale, delocalize does not guess the default. It returns null, and the caller decides what to fall back to.
  • What is left of /en and of /en/ is /. The other way round, localize('/', 'en') returns /en.
Warning

Pitfall

localize does not check for a segment that is already there: localize('/en/ui', 'ja') is /ja/en/ui. Always hand it a pathname that went through delocalize. A value that does not start with / throws a TypeError.

Playground

Try building destinations

Type a pathname to see what delocalize returns, and what localize makes of it for each locale. The set is this site’s own locales (ja and en).

delocalize('/ja/products/42')
{ locale: 'ja', pathname: '/products/42' }
localize('/products/42', 'ja')
'/ja/products/42'
localize('/products/42', 'en')
'/en/products/42'

Try it

  1. For the /ja/products/42 already there, locale is ja, pathname is /products/42, and the destination for en is /en/products/42.
  2. Change it to /english. The first segment is no locale, so locale becomes null.
  3. Change it to /en/. The pathname left once the segment is off is /.
  4. Change it to products, which does not start with /. The rows show the TypeError that localize throws.

Build a language switcher

The switcher’s links are built from the pathname of the page it is on. That pathname comes from @k8ordo/router’s usePathname(), so the switcher is a Client Component.

language-switcher.tsx
'use client';

import type { Variants } from '@k8ordo/i18n';
import { usePathname } from '@k8ordo/router';

import { locales } from '../i18n';

const NAMES: Variants<string> = { ja: '日本語', en: 'English' };

export function LanguageSwitcher() {
  const { pathname } = locales.delocalize(usePathname());
  const current = locales.getLocale();

  return (
    <ul>
      {locales.all.map((locale) => (
        <li key={locale}>
          <a
            aria-current={locale === current ? 'true' : undefined}
            href={locales.localize(pathname, locale)}
            hrefLang={locale}
            lang={locale}
          >
            {NAMES[locale]}
          </a>
        </li>
      ))}
    </ul>
  );
}

usePathname() rather than location, because the server render has no location. usePathname() returns the page’s pathname on the server too, and renders again with the new one after every navigation.

A plain <a> is enough: @k8ordo/router takes the navigation over through the Navigation API, so the whole page is not reloaded. The page renders again, and its messages read the new URL’s locale.

The typed href from bindParams is not used here, because what the switcher holds is the concrete pathname of the page it is on, not a pattern.

Variants<string> holds one value for each locale in Register. Add a locale and forget its name here, and it fails to compile.

Information

Note

localize and delocalize deal in pathnames only, so the query (everything after ?) is not carried over. Append it yourself if it should survive the switch.

In an app served under Vite’s base, add it with withBase(locales.localize(pathname, locale)): neither the pathname usePathname() returns nor what localize returns carries the base.

Remember the choice

Write the chosen language to a cookie, and the next visit to / can use it: under @k8ordo/server, the negotiateRequest that the guard answering / calls reads that cookie before Accept-Language.

remember-locale.ts
import type { Locale } from './i18n';

const MAX_AGE = 400 * 24 * 60 * 60 * 1000;

export const rememberLocale = (locale: Locale) =>
  cookieStore.set({
    name: 'locale',
    value: locale,
    sameSite: 'lax',
    expires: Date.now() + MAX_AGE,
  });

Write it when a switcher link is pressed.

language-switcher.tsx
<a
  aria-current={locale === current ? 'true' : undefined}
  href={locales.localize(pathname, locale)}
  hrefLang={locale}
  lang={locale}
  onClick={() => {
    void rememberLocale(locale);
  }}
>

Left to the Cookie Store API’s defaults, the cookie is gone once the browser closes, and it is written SameSite=Strict, which keeps it off the first request arriving from a link on another site. Write it with sameSite: 'lax' and a far expires instead; 400 days is the longest a browser keeps a cookie.

The cookie’s name is the app’s to choose. Hand the same name to the negotiateRequest that reads it, as { cookie: 'locale' }. “Choose the first language” covers that side.

Information

Note

An @k8ordo/static site has no server to read the cookie when / is opened. “Choose the first language” also shows the / page reading it in the browser.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2