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/delocalizeto put it on and take it off, and theparamsSchemaof the[locale]route. - Negotiation from
navigator.languagesorAccept-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 areIntlformatters. - 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| Package | Version | Needed for |
|---|---|---|
typescript | >= 7.0.2 | The 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.
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 endnot-found.tsxanswers 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.
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
Registeris merged, a message missing a locale does not compile. From JavaScript, or throughas, it throws where it is read, naming the missing locale and the ones present, and never returnsundefined. - 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.