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.
locales.delocalize('/ja/products/42');
// { locale: 'ja', pathname: '/products/42' }
locales.localize('/products/42', 'en');
// '/en/products/42'delocalizeworks by segment. The first segment of/englishisenglish, which is no locale, so it returnslocale: null.- When the first segment is not a locale,
delocalizedoes not guess the default. It returnsnull, and the caller decides what to fall back to. - What is left of
/enand of/en/is/. The other way round,localize('/', 'en')returns/en.
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.
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
- For the
/ja/products/42already there,localeisja,pathnameis/products/42, and the destination forenis/en/products/42. - Change it to
/english. The first segment is no locale, solocalebecomesnull. - Change it to
/en/. Thepathnameleft once the segment is off is/. - Change it to
products, which does not start with/. The rows show theTypeErrorthatlocalizethrows.
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.
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.tsimport 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.
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.