React
@tolki/lang/react hands a lang to a React 19 tree. <LangProvider> provides it, useLang() reads it, and a component that reads it renders again when the locale changes.
Setup with Inertia
Create the lang outside the tree, once for the page on the client and once for each request on the server, then give it to <LangProvider lang={lang}>. createLang is the core function, re-exported from @tolki/lang/react, and takes the options of createLang.
On the client, read the starting locale from the Inertia page prop props.initialPage.props.locale, the value the server rendered with, and start in "en" when the exporter did not write that locale. Wait for lang.ready before hydrateRoot, so the first render already has its lines and matches the server's markup. The locale never comes from navigator.language.
// resources/js/app.tsx
import { createInertiaApp } from "@inertiajs/react";
import { createLang, LangProvider } from "@tolki/lang/react";
import { load, locales } from "virtual:tolki-lang";
import { hydrateRoot } from "react-dom/client";
createInertiaApp({
resolve: (name) => import(`./Pages/${name}.tsx`),
async setup({ el, App, props }) {
const wanted = props.initialPage.props.locale as string;
const locale = locales.includes(wanted) ? wanted : "en";
const lang = createLang({ locale, fallback: "en", load });
await lang.ready;
hydrateRoot(
el,
<LangProvider lang={lang}>
<App {...props} />
</LangProvider>,
);
},
});The Vite plugin supplies load and locales. A page without Inertia has no page prop, so read the locale from its <html lang> with htmlLocale(locales) ?? "en", from @tolki/lang. Share the locale with Inertia, and have Laravel put it in the <html> tag as well, as the Vue page shows. A client app without server-side rendering uses createRoot where the example uses hydrateRoot.
Server-side rendering
On the server, build one createLang() for each request, inside setup, from page.props.locale with the same guard. The module the Vite plugin serves to the server build loads every locale up front, so the lang has its lines at once and there is nothing to await.
// resources/js/ssr.tsx
import { createInertiaApp } from "@inertiajs/react";
import createServer from "@inertiajs/react/server";
import { createLang, LangProvider } from "@tolki/lang/react";
import { renderToString } from "react-dom/server";
import { load, locales } from "virtual:tolki-lang";
createServer((page) =>
createInertiaApp({
page,
render: renderToString,
resolve: (name) => import(`./Pages/${name}.tsx`),
setup: ({ App, props }) => {
const wanted = page.props.locale as string;
const locale = locales.includes(wanted) ? wanted : "en";
const lang = createLang({ locale, fallback: "en", load });
return (
<LangProvider lang={lang}>
<App {...props} />
</LangProvider>
);
},
}),
);WARNING
Warning: Never create the lang at module level on a server. One Lang shared across requests would share its locale between requests that run at the same time. Create it inside setup, so each request has its own.
The client has to start in the locale the server rendered, or hydration reports a mismatch. Both sides read the same prop and apply the same guard because the server and the client must start in the same locale: on the server, load throws for a locale the exporter did not write, and the guard starts that request in "en" instead.
useLang
useLang() gives the reads __, trans and transChoice, plus has, hasForLocale, getLocale, getFallback, isLocale and isLoaded, the current locale, setLocale, and lang, the framework-agnostic lang behind them. It throws an Error when no <LangProvider> is above the component.
Every component that calls useLang() renders again when the lang changes, and a memoized child that receives __ as a prop gets a new function, so it renders again too. setLocale loads the locale, then switches. The old locale stays on screen until the new lines arrive.
import { useLang } from "@tolki/lang/react";
import { locales } from "virtual:tolki-lang";
export function LanguageSwitcher() {
const { __, locale, setLocale } = useLang();
return (
<label>
{__("Language")}
<select value={locale} onChange={(e) => void setLocale(e.target.value)}>
{locales.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</select>
</label>
);
}A failed load leaves the locale as it was and rejects the promise setLocale returns. To handle it, await setLocale(code) in a try block.
import { useLang } from "@tolki/lang/react";
export function Apples({ count }: { count: number }) {
const { __, transChoice } = useLang();
return (
<>
<h1>{__("Welcome")}</h1>
<p>{transChoice("messages.apples", count)}</p>
</>
);
}Typing
Without any setup, __ answers string or a group, and JSX does not take a group as a child. So either name your lines once, or wrap the call in String(). To name them, augment LangRegister from @tolki/lang with the type of your default locale's file. Keys then complete in your editor, and a result is typed by the line it names.
// resources/js/lang.d.ts
import type en from "./lang/en.json";
declare module "@tolki/lang" {
interface LangRegister {
lines: typeof en;
}
}Importing a JSON file as a type needs "resolveJsonModule": true in tsconfig.json. Pass a type argument, as in useLang<typeof lines>(), to override the registered lines for one call.
Switching locale on navigation
An Inertia visit can change the locale. Inertia has no part in the lang, so listen for its navigate event and hand the page's locale to setLocale. setLocale notifies the tree even when the locale is the same, so check isLocale first.
import { router } from "@inertiajs/react";
import { locales } from "virtual:tolki-lang";
router.on("navigate", async (event) => {
const wanted = event.detail.page.props.locale as string;
const locale = locales.includes(wanted) ? wanted : "en";
if (!lang.isLocale(locale)) {
await lang.setLocale(locale);
}
});The guard is the one setup uses, so a visit to a locale the exporter did not write switches to "en" rather than leaving setLocale to reject. Register it in app.tsx, right after you create lang. setLocale does not change <html lang>, so set document.documentElement.lang in the same handler if the page needs it.
Next
- Vite Plugin exports the lines and serves
load. - Vue and Svelte have the same API for the other two frameworks.