@k8ordo/i18n

Get Started

@k8ordo/i18n owns the locale axis of an application. This page defines the locale set in one place, ties it to the [locale] segment, and renders a first message from a Server Component and from a Client Component.

What it owns

Which locales exist, and which one is the default. Everything that depends on that list comes from the one value defineLocales returns.

  • The locale set and its default, including membership and the BCP 47 check.
  • The first URL segment (/ja/…): localize / delocalize to put it on and take it off, and the paramsSchema of the [locale] route.
  • Negotiation from navigator.languages or Accept-Language.
  • The locale of the render in progress: the segment the request accepted on the server, the URL in the browser.
  • Messages: functions declared one at a time with message(), each returning its text in the locale where it is called.

What it does not own

  • The pathname. Everything after the locale segment belongs to @k8ordo/router.
  • A message grammar. There is no placeholder syntax and no ICU: interpolation is a template literal, plurals are Intl.PluralRules, and dates and numbers are Intl formatters.
  • Loading. Messages are ordinary exports, so which of them reach the browser is decided by the bundler from what each Client Component imports. There is no loader and no namespace list.

Because the locale lives in the URL, the browser holds no state to keep in sync: changing language is navigating to the same pathname under another segment. Because a message is a function, TypeScript checks its arguments and the bundler drops the ones nobody calls.

Installation

It has no runtime dependencies. It imports neither React nor a schema library, so the only peer dependency is TypeScript, for the shipped type declarations.

npm install @k8ordo/i18n
PackageVersionNeeded for
typescript>= 7.0.2The shipped type declarations (optional)

On a server the current locale rides on AsyncLocalStorage from node:async_hooks. It is reached through process.getBuiltinModule rather than an import, so the same build runs unchanged in the browser (the framework modes require Node 24). In a runtime without process.getBuiltinModule there is nowhere to scope a locale to, so paramsSchema throws when it accepts one, and so does run.

Define the locale set once

This module is the only place the list is spelled. The [locale] schema, the static path expansion, the language switcher, the / redirect and the type of every message all read from it.

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

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

declare module '@k8ordo/i18n' {
  interface Register {
    locale: LocaleOf<typeof locales>;
  }
}

The first locale is the default. To make another one the default, write defineLocales(['en', 'ja'], { default: 'ja' }). The default is used when negotiation finds nothing and when nothing names a locale.

Register is merged once. From then on every message() is checked against this union of locales. It is an interface rather than a type because it exists to be merged.

Read what the set returns, and how negotiation works

Tie it to the [locale] segment

Put every page under src/routes/[locale]/ and export the set's paramsSchema from that segment's layout; @k8ordo/static and @k8ordo/server are what run a route's paramsSchema. No schema library is needed: the set produces a Standard Schema itself.

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

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

export const { paramsSchema } = locales;

export default function LocaleLayout({ children }: { children: ReactNode }) {
  return children;
}
  • A locale outside the list is refused. /fr/… becomes a pathname this pattern does not answer, and in the end not-found.tsx answers it under a real 404.
  • The accepted locale becomes the current one for the render of the page that accepted it, and reaches no other page or 404: Server Components, and the Client Components that run on the server to produce the HTML, read their messages in it.
  • This file must not be 'use client': a value exported from a client module reaches the handler as a client reference, not as a schema. If the frame needs hooks, move it into a Client Component under _parts/ and render that from the layout.

Read where the locale comes from on each side

A first message

Each message is its own export. A message that takes values is a function returning the text for its locale.

// src/messages/home.ts
import { message } from '@k8ordo/i18n';

export const title = message({ ja: 'ようこそ', en: 'Welcome' });

export const nameLabel = message({ ja: '名前', en: 'Name' });

export const greeting = message({
  ja: (name: string) => `こんにちは、${name}さん`,
  en: (name) => `Hello, ${name}`,
});

The argument type comes from the function annotated for ja, and en is held to that type. Leave out either locale and the declaration does not compile.

From a Server Component

Call it. On the server it returns the text for the locale the [locale] schema accepted for this request.

// src/routes/[locale]/page.tsx
import * as home from '../../messages/home';
import { Greeting } from './_parts/greeting';

export default function HomePage() {
  return (
    <main>
      <h1>{home.title()}</h1>
      <Greeting />
    </main>
  );
}

From a Client Component

The same line. There is no provider and no hook. In the browser the first segment of the URL is the locale, so on /en/… it returns the English text.

// src/routes/[locale]/_parts/greeting.tsx
'use client';

import { useState } from 'react';

import * as home from '../../../messages/home';

export function Greeting() {
  const [name, setName] = useState('k8o');

  return (
    <div>
      <label>
        {home.nameLabel()}
        <input
          onChange={(event) => {
            setName(event.currentTarget.value);
          }}
          value={name}
        />
      </label>
      <p>{home.greeting(name)}</p>
    </div>
  );
}

When a Server Component hands text to a Client Component as a prop, it passes the string it got by calling the message: a function does not cross the Server Component boundary.

Read how text crosses the boundary, and what reaches the bundle

What is guaranteed

  • A locale outside the list never reaches a page: the schema refuses it.
  • Once Register is merged, a message missing a locale does not compile. From JavaScript, or through as, it throws where it is read, naming the missing locale and the ones present, and never returns undefined.
  • The arguments of a message that takes values are checked by the function's type.
  • A page under [locale] renders in the locale its URL spells: the one its schema accepted on the server, the URL itself in the browser.

Next steps