@k8ordo/i18n

URLs & locale

The locale lives in the first segment of the URL. The server reads it from the segment the request accepted and the browser from the URL it is on, so no state anywhere has to keep the locale in sync. This page covers both sides, then the language switcher, the / page, <html lang>, static builds and typed links.

Where the locale comes from

Messages and getLocale() read the locale from these places, depending on where they are called.

WhereThe localeWhen nothing names one
Server (Server Components, and Client Components running on the server for the HTML)The locale paramsSchema accepted for this request; inside run(locale, fn), locale.The default locale
BrowserThe first segment of location.pathname, read at every call.The default locale (a segment outside the set counts as none)

On the server the locale rides on AsyncLocalStorage, so concurrent renders of different requests stay apart. The RSC environment and the SSR environment (where Client Components render on the server) are separate module graphs in one process, which is why the storage and the registered set live on globalThis.

Which path is taken is decided once, when the module loads, by whether document is defined.

Before the module that defines the set has been evaluated in the browser, there is no way to tell whether a segment is a locale. Until then, a segment the message has no text for is read as no locale, and the first text written is used. That is why a 404 page on /fr/… does not throw.

On the server: paramsSchema

When the [locale] segment's layout writes export const { paramsSchema } = locales, the framework runs that schema before anything renders. The generator parses the file for the export, so the destructuring spelling counts too.

// src/routes/[locale]/layout.tsx
import type { ReactNode } from 'react';

import { locales } from '../../i18n';
import { LocaleShell } from './_parts/locale-shell';

export const { paramsSchema } = locales;

export default function LocaleLayout({ children }: { children: ReactNode }) {
  return <LocaleShell>{children}</LocaleShell>;
}
  • It has the Standard Schema v1 shape (vendor @k8ordo/i18n) and depends on no schema library. Validation is synchronous.
  • A locale outside the list gets an issue at path: ['locale']. The pattern does not answer, the walk moves on through the table, and in the end not-found.tsx answers with a 404.
  • The accepted value is { locale } alone; other params keep their strings for the schemas that follow.
  • Accepting is also where that page's render begins: the accepted locale reaches the Server Components after it, the step that turns them into HTML, and the Client Components that run on the server, and no other page or 404. When a later schema in the same stack refuses (/en/blog/nope), the acceptance is dropped with the pattern, and the 404 renders in the default, as /en/nothing does. In a runtime with no AsyncLocalStorage to offer, accepting throws.
  • The file that exports the schema must be a Server Component; a frame that uses hooks goes into a Client Component under _parts/.
  • The layout still receives params.locale as a string, even though it exports the schema: under the /:locale/* not-found.tsx no schema runs, and the value arrives unvalidated.

On the server: run(locale, fn)

Use it to run something under a chosen locale outside a [locale] render: a Server Action, a batch job, building an email, a test. It returns what fn returns, and when fn is async the locale is kept across its awaits.

// src/emails/welcome.ts
import { locales } from '../i18n';
import type { Locale } from '../i18n';
import * as m from '../messages';

export const welcomeSubject = (locale: Locale): string =>
  locales.run(locale, () => m.email.welcomeSubject());

It throws in the browser, where the URL is the locale and changing it means navigating. It also throws in a runtime that has no AsyncLocalStorage to offer.

getLocale() is not a hook

getLocale() returns the tag itself, from the same source messages read. It is not a hook, so it can be called during render, in an event handler, inside a message, or in a bindParams source. Use it for <html lang>, Intl formatters, and the current value of a language switcher.

It does not depend on this, so it can be taken out of the set and exported, as this site's src/i18n.ts does.

// src/i18n.ts
import { defineLocales } from '@k8ordo/i18n';
import type { LocaleOf } from '@k8ordo/i18n';

export const locales = defineLocales(['ja', 'en']);

export type Locale = LocaleOf<typeof locales>;

declare module '@k8ordo/i18n' {
  interface Register {
    locale: Locale;
  }
}

export const { getLocale } = locales;
// src/components/published-at.tsx
import { getLocale } from '../i18n';

export function PublishedAt({ date }: { date: Date }) {
  return (
    <time dateTime={date.toISOString()}>
      {new Intl.DateTimeFormat(getLocale(), {
        dateStyle: 'medium',
        timeZone: 'UTC',
      }).format(date)}
    </time>
  );
}
  • It does not subscribe. Called in a Client Component, it reads the URL at that moment. A locale changes by navigating, so the re-render of the page reads the new value; a component that must re-render on its own when the URL changes reads usePathname() from @k8ordo/router.
  • A value computed at the top of a module stays fixed to the locale at load time.

A language switcher: localize and delocalize

Changing language is navigating to the same pathname under another locale's segment: take the segment off the current pathname (delocalize) and put another one on (localize).

// src/components/language-switcher.tsx
'use client';

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

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

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

export function LanguageSwitcher() {
  const { pathname } = locales.delocalize(usePathname());
  const current = 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}
          >
            {LABELS[locale]}
          </a>
        </li>
      ))}
    </ul>
  );
}
  • delocalize works by segment: the first segment of /english is english, so it gives locale: null. What remains of /en and /en/ is /.
  • When the first segment is not a locale, delocalize returns locale: null rather than guessing the default, so the caller chooses the fallback where it can be seen.
  • localize does not check for a segment already there: localize('/en/ui', 'ja') is /ja/en/ui. Always hand it a pathname that went through delocalize. It throws a TypeError on a value that does not start with /.
  • Both deal in pathnames only. If a page keeps state in the search and it should survive the switch, append location.search yourself.

usePathname() is used because the server render has no location, hydration has to render the value the server used, and the links have to be redrawn after every navigation. href from a pattern is not used because what the switcher holds is the concrete pathname of the page it is on, not a pattern. A plain <a> is still a client navigation, because the router intercepts it through the Navigation API.

The / page

/ is the one URL without a locale. It renders nothing, and negotiates and moves to a localized URL from an effect.

// src/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;
}
  • It navigates from an effect, not during render: the server render (at build time for a static site) runs outside a browser, where there is no Navigation API, and where navigator.languages, if present, belongs to the machine rendering the page rather than the visitor.
  • history: 'replace' keeps the back button from returning to / only to be redirected again.
  • Without @k8ordo/router, location.replace(locales.localize('/', locale)) does the same job.
  • Under @k8ordo/server, the page can negotiate on the server from the Accept-Language of the request it receives.

Read how it is written under @k8ordo/server

<html lang>

lang has to be right in the HTML the server writes, because crawlers and screen readers read it without waiting for hydration. The root layout sits above [locale], but it receives pathname.

// src/routes/layout.tsx
import type { ReactNode } from 'react';

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

export default function RootLayout({
  children,
  pathname,
}: {
  children: ReactNode;
  pathname: string;
}) {
  const locale = locales.delocalize(pathname).locale ?? locales.default;

  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  );
}

Schemas run before anything renders, so on a page whose schema accepted, locales.getLocale() returns the same value. The delocalize form reads as depending on the URL alone, and it still reads the segment of the pathname in a 404 render, where nothing validates the catch-all's params.

Static builds: locales.paths

@k8ordo/static asks for concrete pathnames for every pattern with a parameter. The locale segment takes the same values on every page, so the set expands it itself.

// vite.config.ts
import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';

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

export default defineConfig({
  plugins: [framework({ paths: locales.paths })],
});
  • A pattern with a /:locale segment becomes one pathname per locale. Replacement is by segment, so a different param such as /:localeCode is left alone.
  • A pattern without /:locale comes back as it is. The static build hands paths only the patterns that still need pathnames, so a pattern returned unchanged stays unexpanded and stops the build; expand its parameters in the same function.
  • Each pathname is rendered as its own request, so the schema accepts the locale for each page and the messages come out in it. The build renders several pages at once, and none lends its locale to another.

When there is another parameter

A pattern with another parameter, such as /:locale/blog/:slug, still has :slug after locales.paths (/ja/blog/:slug). The static build does not use a pathname that still holds a parameter, so the pattern counts as unexpanded and the build stops with static build needs pathnames for /:locale/blog/:slug — supply them with the "paths" option. Expand the remaining parameters in the same function.

// vite.config.ts
import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';

import { locales } from './src/i18n';
import { readSlugs } from './src/posts';

export default defineConfig({
  plugins: [
    framework({
      paths: async (patterns) => {
        const slugs = await readSlugs();
        return locales
          .paths(patterns)
          .flatMap((path) =>
            path.includes('/:slug')
              ? slugs.map((slug) => path.replace('/:slug', `/${slug}`))
              : [path],
          );
      },
    }),
  ],
});

The 404.html a static host serves for every URL it does not have is rendered once, under the build's sentinel segment. No schema accepts that segment, so its text is in the default locale and cannot follow the visitor's. Client Components read the visitor's URL when they hydrate and render again in that locale. That is why this site's not-found.tsx is a Client Component.

Typed links: bindParams

The locale is a parameter of every pattern (/:locale/products/:id). Hand @k8ordo/router's bindParams the locale source once, and links keep the pattern's spelling, stay checked against the route table, and never spell the locale.

// src/links.ts
import { bindParams } from '@k8ordo/router';

import { locales } from './i18n';

export const { href, navigateTo } = bindParams(() => ({
  locale: locales.getLocale(),
}));
// src/components/product-link.tsx
import { href } from '../links';

export function ProductLink({ id, name }: { id: string; name: string }) {
  return <a href={href('/:locale/products/:id', { id })}>{name}</a>;
}
  • The source is read at every call: the request's locale on the server, the current URL's locale in the browser.
  • Giving the locale explicitly overrides the source: navigateTo('/:locale', { locale: 'en' }, { history: 'replace' }) goes to the English top page.
  • Neither package imports the other; this one line in the application is what ties them.
  • localize / delocalize remain for when what you hold is a concrete pathname rather than a pattern: the language switcher.

Read @k8ordo/router's page on links