Guides
i18n with Paraglide JS and i18next
Translate a site with Paraglide JS or i18next, in server pages and islands, with a language switcher and links search engines understand.
Lumiose routes locales (Internationalization): it reads the URL prefix,
declares which pages exist in which locale, builds localized links and writes <html lang>,
hreflang and canonical links. It does not translate or format anything: messages, plurals,
numbers and dates belong to a library. This guide builds the same small site (a bakery in English,
Spanish and French) with the two most used ones:
- Paraglide JS compiles messages into one function per message. Pages and islands import the functions they call, so an island's chunk holds only its own messages, and keys and parameters are type-checked.
- i18next looks keys up in JSON resources at runtime, with
namespaces, plurals and
Intlformatting, and a large ecosystem of tools.
Each is a sample with an e2e test on the production build: ssr-i18n-paraglide and ssr-i18n-i18next.
| File | Role |
|---|---|
vite.config.ts |
lumiose({ i18n, site }), plus Paraglide's Vite plugin. |
src/routes/page.stsx |
The home page, in every locale. |
src/routes/about/page.stsx |
A page in English and Spanish only, prerendered per locale. |
src/routes/layout.stsx |
Navigation and the language switcher, in the page's locale. |
src/routes/not-found.stsx |
The 404 page, in the locale of the URL. |
src/islands/LocaleSwitcher.stsx |
Links to the page in each of its locales; updates the detection cookie. |
src/islands/Basket.stsx |
An island with messages: a plural that changes on click. |
src/lib/i18n.ts |
The library's setup, shared by pages and islands. |
src/server/i18n.server.ts |
i18next's server instance (i18next sample). Server-only. |
#The site
The routing is the same with both libraries:
// vite.config.ts
import lumiose from "@lumiose/vite";
export default {
plugins: [
lumiose({
mode: "server",
i18n: {
locales: ["en", "es", "fr"],
defaultLocale: "en",
missing: "404", // the i18next sample uses "redirect"
detect: { cookie: "locale", acceptLanguage: true },
},
site: "https://bakery.example",
}),
],
};The home page exists in every locale (export const locales = "all") and renders per request, so
detection can send a Spanish browser from / to /es. /about is translated into English and
Spanish only, and prerendered:
// src/routes/about/page.stsx
export const mode = "static";
export const locales = ["en", "es"];So /es/about exists, and /fr/about is a 404 (or, with missing: "redirect", a redirect to
/about). A page without locales exists only in English. Translations are a per-page decision,
not a site-wide switch.
#The language switcher
alternates() lists the current page in each locale it declares, and the switcher links to them,
named in their own language. It is an island because a click also updates the cookie that detection
reads: without that, a visitor who switched from Spanish to French would land on Spanish again on
their next visit to /. The layout renders it with the alternates as a prop (alternates() reads the
request, so it only runs on the server):
// src/routes/layout.stsx (shortened, Paraglide sample)
<LocaleSwitcher client:load label={m.nav_language()} alternates={alternates()} />// src/islands/LocaleSwitcher.stsx
import type { Locale } from "lumiose";
import { Link } from "lumiose/router";
const NAMES: Record<Locale, string> = { en: "English", es: "Español", fr: "Français" };
export function LocaleSwitcher(props: {
label: string;
alternates: Array<{ locale: Locale; href: string }>;
}) {
return (
<nav class="locales" aria-label={props.label}>
{props.alternates.map((alternate) => (
<Link
href={alternate.href}
hreflang={alternate.locale}
lang={alternate.locale}
onClick={() => rememberLocale(alternate.locale)}
>
{NAMES[alternate.locale]}
</Link>
))}
</nav>
);
}
/** The cookie of `detect: { cookie: "locale" }`, with the attributes Lumiose uses. */
function rememberLocale(next: Locale) {
const secure = location.protocol === "https:" ? "; secure" : "";
document.cookie = `locale=${next}; path=/; max-age=31536000; samesite=lax${secure}`;
}- Only declared locales. On
/aboutthe switcher shows English and Español; there is no French link to a page that does not exist. <Link>marks the current locale's link witharia-current="page". The links prefetch like any other: with detection on, Lumiose never prefetches/, whose answer depends on the cookie this click changes (Internationalization).- Without JavaScript the links still work; only the cookie is not updated. To update it on the
server instead, make each locale a button of a form that posts to an action, which sets the
cookie with
cookies().set()(path/, a year) and redirects to the alternate's URL.
#Links to pages that are not translated
href("/about") keeps the current locale, so on a French page it is /fr/about, which does not
exist. alternates("/about") lists the locales the page declares, [{ locale, href }], so link it
in the current locale when it has one, else in another:
// src/routes/layout.stsx: /about in the current locale when it is translated, else in English.
function aboutHref(): string {
const translated = alternates("/about").find((each) => each.locale === locale());
return translated?.href ?? href("/about", { locale: "en" });
}A route with params takes them like href(): alternates("/blog/[slug]", { slug }). A route whose
locales come from a locales({ params }) function throws there, since rendering cannot wait for
it: build those links from the translations your content lists.
#Paraglide JS
#Setup
pnpm add -D @inlang/paraglide-js @inlang/plugin-message-formatParaglide compiles an inlang project: project.inlang/settings.json lists the locales and the
plugin that reads the message files. npx @inlang/paraglide-js init writes it with the plugin's
CDN URL, which the compiler downloads on every build; the samples load it from node_modules
instead, so builds need no network and the version is in the lockfile:
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "es", "fr"],
"modules": ["./node_modules/@inlang/plugin-message-format/dist/index.js"],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}The locales are the ones in lumiose({ i18n }), with the default locale as baseLocale. Messages
are one JSON file per locale, with {placeholders}, and declarations for plurals and formatting:
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"home_title": "Fresh bread every morning",
"home_price": [
{
"declarations": [
"input price",
"local formattedPrice = price: number style=currency currency=EUR"
],
"match": { "price=*": "A croissant costs {formattedPrice}." }
}
],
"basket_count": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} croissant in your basket",
"countPlural=other": "{count} croissants in your basket"
}
}
]
}The Vite plugin compiles them into src/paraglide/ when lumiose dev and lumiose build start,
and again when a message file changes. Add src/paraglide/ to .gitignore:
// vite.config.ts
import { paraglideVitePlugin } from "@inlang/paraglide-js";
import lumiose from "@lumiose/vite";
export default {
plugins: [
paraglideVitePlugin({
project: "./project.inlang",
outdir: "./src/paraglide",
emitTsDeclarations: true,
}),
lumiose({
mode: "server",
i18n: {
locales: ["en", "es", "fr"],
defaultLocale: "en",
missing: "404",
detect: { cookie: "locale", acceptLanguage: true },
},
site: "https://bakery.example",
}),
],
};lumiose check does not run Vite plugins, so a fresh checkout compiles the messages first:
{
"scripts": {
"typecheck": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide --emit-ts-declarations --silent && lumiose check"
}
}#The locale
Every message function reads the locale from the runtime's getLocale(). Point it at Lumiose's
locale(), in the one module that pages and islands import messages from:
// src/lib/i18n.ts
import { locale } from "lumiose/router";
import { overwriteGetLocale } from "../paraglide/runtime.js";
overwriteGetLocale(() => locale());
export { m } from "../paraglide/messages.js";locale() from lumiose/router is the request's locale on the server, read from the request's
async context, so concurrent requests in different locales never mix, and prerendering renders
each locale with its own. In the browser it is the page's locale (reactive in spa routes). Leave
the rest of Paraglide's locale handling unused: Lumiose already routes the prefix, detects on entry
and writes <html lang>, so Paraglide's url strategy, paraglideMiddleware(),
localizeHref() and setLocale() would do the same work a second time, with their own cookie.
#Messages in pages
// src/routes/page.stsx
import { Title } from "lumiose";
import { Basket } from "../islands/Basket.stsx";
import { m } from "../lib/i18n.ts";
export const locales = "all";
export default function Home() {
return (
<main>
<Title>{m.site_name()}</Title>
<h1>{m.home_title()}</h1>
<p>{m.home_price({ price: 1.2 })}</p>
<Basket client:visible />
</main>
);
}m.home_price({ price: 1.2 }) is "A croissant costs €1.20." on / and "Un croissant coûte
1,20 €." on /fr: Paraglide formats with Intl for the locale. A message missing in a locale falls
back to the base locale's. The not-found page uses the same calls, so /fr/about says "Page
introuvable".
#Messages in islands
Islands import the same functions:
// src/islands/Basket.stsx
import { signal } from "lumiose";
import { m } from "../lib/i18n.ts";
export function Basket() {
const count = signal(0);
return (
<div class="basket">
<p aria-live="polite">{m.basket_count({ count: count() })}</p>
<button type="button" onClick={() => count.update((n) => n + 1)}>
{m.basket_add()}
</button>
</div>
);
}The island renders on the server in the request's locale and runs again in the browser in the page's locale, so hydration finds the same text. Its chunk holds the two messages it calls, in the three locales, and none of the others: 1.8 kB with brotli in the sample, Paraglide's runtime included.
#i18next
#Setup
pnpm add i18nextThe sample keeps one JSON file per locale and namespace: site for the pages, basket for the
island.
src/locales/en/basket.json:
{
"add": "Add a croissant",
"count_one": "{{count}} croissant in your basket",
"count_other": "{{count}} croissants in your basket"
}src/locales/en/site.json (shortened):
{
"name": "Sunrise Bakery",
"home": {
"title": "Fresh bread every morning",
"price": "A croissant costs {{price, currency(EUR)}}."
}
}The options and the key types live in a module that the server and islands share:
// src/lib/i18n.ts
import i18next, { type TFunction } from "i18next";
import type basket from "../locales/en/basket.json";
import type site from "../locales/en/site.json";
export interface Namespaces {
site: typeof site;
basket: typeof basket;
}
declare module "i18next" {
interface CustomTypeOptions {
defaultNS: "site";
resources: Namespaces;
}
}
export const options = {
fallbackLng: "en",
// Synchronous init: resources are inline, so `t` works right after init().
initAsync: false,
// Lumiose escapes text when it renders it; escaping values here too would show `&`.
interpolation: { escapeValue: false },
} as const;With CustomTypeOptions, lumiose check reports a key that does not exist (t("home.titel"))
and suggests the right one.
#A t per request
The server has one instance with every locale's resources, created once and never switched to a
language. Each request asks for a t fixed to its locale:
// src/server/i18n.server.ts
import i18next, { type TFunction } from "i18next";
import { locale } from "lumiose";
import { options, type Namespaces } from "../lib/i18n.ts";
import enBasket from "../locales/en/basket.json" with { type: "json" };
import enSite from "../locales/en/site.json" with { type: "json" };
// … es and fr
const i18n = i18next.createInstance({
...options,
supportedLngs: ["en", "es", "fr"],
ns: ["site", "basket"],
defaultNS: "site",
resources: {
en: { site: enSite, basket: enBasket },
es: { site: esSite, basket: esBasket },
fr: { site: frSite, basket: frBasket },
},
});
void i18n.init();
/** `t` in the request's locale. */
export function getT(): TFunction<"site"> {
return i18n.getFixedT(locale());
}
/** One namespace in the request's locale, for an island's props. */
export function messages<NS extends keyof Namespaces>(ns: NS): Namespaces[NS] {
return i18n.getResourceBundle(locale(), ns) as Namespaces[NS];
}getFixedT() returns a function bound to a language without changing the instance, so two
requests in different locales can render at the same time. i18n.changeLanguage() per request
would not be safe: the instance is shared, and a request that awaits would continue in the language
of whichever request changed it last. The module is server-only (*.server.ts): islands cannot
import it (LUM4003), and the browser never downloads every locale. In a
component, take a t and use it:
// src/routes/page.stsx
import { Title } from "lumiose";
import { Basket } from "../islands/Basket.stsx";
import { getT, messages } from "../server/i18n.server.ts";
export const locales = "all";
export default function Home() {
const t = getT();
return (
<main>
<Title>{t("name")}</Title>
<h1>{t("home.title")}</h1>
<p>{t("home.price", { price: 1.2 })}</p>
<Basket client:visible messages={messages("basket")} />
</main>
);
}getT() also works in middleware, actions and endpoints, which run in the request too
(locale() is ctx.locale there).
#Islands receive their resources
An island gets the namespace it needs, in the page's locale, as a prop (serialized into the page), and creates a small instance from it:
// src/lib/i18n.ts (continued)
export function islandT<NS extends keyof Namespaces>(
locale: string,
ns: NS,
messages: Namespaces[NS],
): TFunction<NS> {
const instance = i18next.createInstance({
...options,
lng: locale,
ns: [ns],
defaultNS: ns,
resources: { [locale]: { [ns]: messages } },
});
void instance.init();
return instance.getFixedT(locale, ns);
}// src/islands/Basket.stsx
import { signal } from "lumiose";
import { locale } from "lumiose/router";
import { islandT, type Namespaces } from "../lib/i18n.ts";
export function Basket(props: { messages: Namespaces["basket"] }) {
const t = islandT(locale(), "basket", props.messages);
const count = signal(0);
return (
<div class="basket">
<p aria-live="polite">{t("count", { count: count() })}</p>
<button type="button" onClick={() => count.update((n) => n + 1)}>
{t("add")}
</button>
</div>
);
}The component runs once per island, so the instance is created once. The island's chunk carries i18next itself, about 12.5 kB with brotli in the sample, and no translations: those arrive as props, only the namespace this island uses and only in the page's locale.
#Search engines
hreflangand canonical links come from the locales a page declares:/es/aboutlinks to/aboutand itself, and not to a French page that does not exist. Each locale is canonical for itself. A<link rel="canonical">the page renders with<Head>replaces Lumiose's.- Absolute URLs. Pages rendered per request use the request's origin (set
ORIGINbehind a proxy, see Deploy); prerendered pages uselumiose({ site }). Withoutsite, a prerendered page's links are root-relative, which search engines ignore, andlumiose buildwarns. - Translate the head too.
<Title>and<Meta name="description">take messages like any text.<html lang>follows the URL's locale. - Translating some pages. Declare
localesper page or per layout. Choosemissing: "404"when an untranslated URL should not exist, or"redirect"when visitors who guess or keep an old URL should land on the default locale's page; neither serves an untranslated duplicate. - Detection only redirects on
/and first visits, withVary: Cookie, Accept-LanguageandCache-Control: private, no-store. Crawlers keep no cookies and usually send noAccept-Language, so they get each URL in its own locale and follow thehreflanglinks. Prerendered pages are files and never redirect.
#Testing
Playwright's locale option sets the browser's Accept-Language, and the context's cookies show
the detection cookie:
test.describe("a first visit from a Spanish browser", () => {
test.use({ locale: "es-ES" });
test("is redirected from / to /es", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveURL("/es");
const cookies = await page.context().cookies();
expect(cookies.find((cookie) => cookie.name === "locale")?.value).toBe("es");
});
});The samples' tests also check <html lang>, the hreflang and canonical URLs, the 404 or redirect
of /fr/about, the switcher and its cookie, and the island's plural after a click.
#Common mistakes
- Changing a shared instance's language per request (
i18next.changeLanguage(), Paraglide'ssetLocale()on the server). Concurrent requests overwrite each other's locale. UsegetFixedT(locale()), andoverwriteGetLocale(() => locale())for Paraglide. - Paraglide's own routing next to Lumiose's (
urlstrategy,paraglideMiddleware(),localizeHref()). Lumiose already does it;href()andalternates()build the links. escapeValue: true(i18next's default). Lumiose escapes text, so an interpolated&would show as&.- Importing
mfromsrc/paraglide/messages.jsdirectly. An island whose chunk never loadssrc/lib/i18n.tskeeps Paraglide's owngetLocale(), which knows nothing of the URL's locale. Import messages from the module that callsoverwriteGetLocale(). - All locales in an island. Importing every resource file into client code ships every translation. Pass the namespace in the page's locale as a prop, or let Paraglide tree-shake.
- A switcher that does not update the detection cookie: the next visit to
/goes back to the old locale. - Linking an untranslated page in the current locale.
href("/about")on a French page is/fr/about, a 404; pick a locale fromalternates("/about"). - Hard-coded prefixes (
"/es" + path). They break for the default locale, which has none, and below abase; usehref(pattern, { locale }).
See also: Internationalization, Islands,
Head and document, lumiose/router.