Concepts
Internationalization
Built-in locale routing: one set of route files served under each locale's prefix, with locale-aware links, hreflang and canonical URLs.
#Concept
// vite.config.ts
lumiose({
i18n: {
locales: ["en", "es", "fr"],
defaultLocale: "en", // served unprefixed (/about); the others are prefixed (/es/about)
missing: "404", // or "redirect" to the default locale's page; site-wide
detect: false, // or { cookie: "locale", acceptLanguage: true }: only on entry
},
site: "https://example.com", // the origin of prerendered pages' absolute URLs
});URLs. Locales are URL path segments (en, pt-BR). The router reads the prefix and matches
the rest against the routes, so src/routes/about/page.stsx serves /about (the default locale)
and /es/about, /fr/about for the locales it declares. The default locale has no prefix:
/en/about is not a URL. There is no [locale] folder (a route param named locale is
LUM5007).
Declaring locales. A page exists in the locales it declares; without a declaration, only in the default locale, so a site can translate some pages and never serves an untranslated duplicate:
export const locales = ["en", "es"]: these locales;"all": every configured locale.export async function locales({ params }): content-driven pages decide per params (a blog post translated into some languages). It runs per request, and once per path while prerendering.- A layout's
localesapplies to every page below it; a page's own export wins. Endpoints (route.ts) declare locales the same way. - A locale the config does not list is a
LUM5002error.
Missing translations. A page requested in a locale it does not declare is a 404 (the
not-found page, in that locale), or with missing: "redirect" a redirect to the default
locale's URL when the page exists there (302, or 307 for other methods than GET).
The request's locale. locale() from lumiose reads it on the server (the URL prefix, else
the default locale), ctx.locale in middleware, actions and endpoints, and locale() from
lumiose/router is reactive in SPA routes (and the page's locale in islands). Locale is the
union of the configured locales, from typegen. Without lumiose({ i18n }) they are undefined,
and typed string | undefined. The document's <html lang> follows: the default
document writes it, a custom src/document.stsx gets the lang of its <html> set, and SPA
navigations update it.
Links. href() builds URLs in the current locale: href("/about") is /es/about on a Spanish
page. The locale key switches it: href("/blog/[slug]", { slug, locale: "fr" }). href() does
not check that the page declares the locale; alternates() lists the URLs that exist.
Alternates and search engines. alternates() returns the current page in every locale it
declares, [{ locale, href }], for language switchers. alternates(pattern, params?) does the same
for another route, to link a page translated into fewer locales than the current one
(alternates("/about")); its params are typed like href()'s. A route whose locales come from a
locales({ params }) function throws there: rendering cannot wait for it, so build those links
from the translations your content lists. A page that declares more than one locale also gets, in
its <head>:
<link rel="alternate" hreflang="…">for each declared locale, and nothing for the others (nox-default), so search engines never see a URL that does not exist;<link rel="canonical">to the page's own URL in its locale (each locale is canonical for itself). A canonical the page or a layout renders with<Head>replaces it.
In spa routes the client router keeps them current: after each navigation it writes the new
page's links, or removes them for a page that declares a single locale, and a canonical that the
new page renders with <Head> still replaces Lumiose's. Hreflang links of the configured locales
belong to Lumiose there: do not write them with <Head>.
These URLs are absolute: the request's origin for pages rendered per request, and
lumiose({ site }) for prerendered pages, which have no request; after an SPA navigation, the
page's origin (location.origin). Without site, prerendered pages
get root-relative URLs, which search engines ignore, and lumiose build warns. The origin is
joined with the href() path, which already has Vite's base: a site with a path
("https://example.com/docs") sets the base when the config has none, and must match it
otherwise.
Prerendering. A static page (or an spa page with prerender) renders once per locale it
declares, for each path of its staticPaths(): about/index.html, es/about/index.html, … A
locales() function is asked for each path's params.
Detection (detect, off by default) runs only on entry, never on every request:
- On a first visit (no
cookie), the visitor'sAccept-Languagelocale (withacceptLanguage: true): a page that declares it redirects there (/about→/es/about), and the cookie records the locale either way. - On
/, the cookie's locale: a visitor who chose Spanish lands on/es. - Deep links never redirect once the cookie exists, prefixed URLs never do, and without a
cookieonly/detects (first visits cannot be told apart). - These responses send
Vary: Cookie, Accept-LanguageandCache-Control: private, no-store. - A prefetch never keeps a decision, since a language switcher may change the cookie before the
navigation: the speculation rules leave out
/, and a detecting request that carriesSec-Purpose: prefetch(another prefetcher, a prerender) gets a503the browser discards, so the navigation itself detects.
A language switcher must update the cookie (cookies().set("locale", "es") in an action, or
document.cookie in an island); the cookie is not HttpOnly, so client code can. Detection runs in
the request handler: a prerendered / served as a file never redirects, so render the entry page
per request (export const mode = "server") to detect there.
SPA routes. The client router matches prefixed URLs too, for the locales a page declares in a
list; a page with a locales() function, or a locale it does not declare, loads as a document and
the server answers. That is a trade-off: the function runs on the server (it usually reads content
or a database), and only the server knows whether the URL exists, so it answers with the page, a
404 or a redirect, and its hreflang links. A navigation to another locale renders every level
again.
Messages. Lumiose does not translate or format messages: use a library such as Paraglide JS
(compile-time messages, only what a page uses) or i18next, keyed by locale(). The
i18n guide shows both end to end.
Status: locale routing, declarations,
missing, detection,locale(),alternates(), locale-awarehref(),<html lang>,hreflangand canonical links, and per-locale prerendering are available. Locales per domain (example.es) come after 1.0.
#API
| API | Use |
|---|---|
lumiose({ i18n: { locales, defaultLocale, missing?, detect? } }) |
Configure locale routing (reference). |
lumiose({ site }) |
The site's URL, for the absolute URLs of prerendered pages. |
export const locales = [...] | "all" |
The locales a page, endpoint or layout (and everything below) has. |
export async function locales({ params }) |
The locales of a content-driven page, per params. |
locale(), ctx.locale |
The request's locale (server). |
locale() from lumiose/router |
The current locale, reactive in SPA routes. |
href(pattern, { ...params, locale }) |
A URL in the current locale, or in locale. |
alternates() |
[{ locale, href }]: the current page in each declared locale. |
alternates(pattern, params?) |
The same for another route, from its locales list. |
Locale |
The configured locales (typegen). |
#Example
// vite.config.ts
import lumiose from "@lumiose/vite";
export default {
plugins: [
lumiose({
i18n: {
locales: ["en", "es"],
defaultLocale: "en",
detect: { cookie: "locale", acceptLanguage: true },
},
site: "https://example.com",
}),
],
};// src/routes/layout.stsx: every page below exists in both locales, with a language switcher
import { alternates, locale, type LayoutProps } from "lumiose";
export const locales = "all";
const names = { en: "English", es: "Español" };
export default function Layout(props: LayoutProps<"/">) {
return (
<div>
<nav>
{alternates().map((alternate) => (
<a href={alternate.href} aria-current={alternate.locale === locale() ? "true" : undefined}>
{names[alternate.locale]}
</a>
))}
</nav>
{props.children}
</div>
);
}// src/routes/blog/[slug]/page.stsx: a post exists in the languages it was translated into
import { href, locale, notFound, type PageProps } from "lumiose";
import { getPost } from "../../../lib/posts.ts";
export async function locales({ params }: { params: { slug: string } }) {
return (await getPost(params.slug))?.translations ?? [];
}
export default async function Post(props: PageProps<"/blog/[slug]">) {
const post = (await getPost(props.params.slug, locale())) ?? notFound();
return (
<article>
<h1>{post.title}</h1>
<a href={href("/blog")}>{post.back}</a>
</article>
);
}#Common mistakes
- A
[locale]folder. The router handles the prefix:src/routes/about/page.stsxserves every locale it declares, and a param namedlocaleisLUM5007. - Expecting every page in every locale. Without
export const locales(on the page or a layout), a page exists only in the default locale;/es/aboutis then a 404. href()to a page that is not translated. On a French pagehref("/about")is/fr/about; pick a locale fromalternates("/about").- Hard-coded prefixes.
"/es" + pathbreaks for the default locale and below abase; usehref(pattern, { locale }). - A switcher that does not update the detection cookie. The visitor's next visit to
/sends them back to the locale in the cookie. - Detection on a prerendered
/. A static file never redirects; render the entry page per request. - No
sitefor a prerendered site.hreflangand canonical URLs stay root-relative, which search engines ignore;lumiose buildwarns.
#Related diagnostics
- LUM5007: route param named
locale. - LUM5002: an invalid
localesexport, or a locale the config does not list.
See also: i18n with Paraglide JS and i18next, Routing,
lumiose: app layer, lumiose/router,
@lumiose/vite.