Internationalization
Serve one app in several languages: locale detection from the URL prefix, the Accept-Language header or the gio_locale cookie, and reading the locale in pages.
GioJS handles the routing half of internationalization: the Rust server decides each request's locale, strips a locale prefix from the URL before routing (so /de/about renders app/about/page.tsx), keys the page cache by locale and sets <html lang>. Translating the text is up to you - a dictionary per locale, or any i18n library that runs in React.
Without an [i18n] section (or with an empty locales list) none of this runs, and the locale is '' everywhere.
Configuration
[i18n]
locales = ["en", "de", "fr"] # empty (the default) turns i18n off
default_locale = "en" # default "en"
detect_from = ["path", "accept-language", "cookie"] # the default order| Key | Type | Default | Description |
|---|---|---|---|
locales | string[] | [] | The locales the app serves, as they appear in URLs. Empty: i18n is off. |
default_locale | string | "en" | The locale when nothing else matches. Its pages need no prefix. |
detect_from | string[] | ["path", "accept-language", "cookie"] | Where to look, in order; the first source that names a configured locale wins. Values: "path", "accept-language", "cookie". |
The full key reference is [i18n].
How the locale is detected
For every request, the sources in detect_from are tried in order:
"path"- the first path segment, when it is one oflocales:/de/aboutisde. Matching is exact (/DE/aboutis not a locale prefix)."accept-language"- the browser's language list. Entries are tried from the highest quality value (;q=, default1) down, in the header's order where they tie, and the first one that matches a locale wins: exactly (ignoring case), or by its language part, sode-ATmatchesde.de;q=0.1, enpicksen. An entry withq=0("not this language") or a malformedqis never picked. The locale is always spelled as inlocales:pt-brin the header gives a configuredpt-BR."cookie"- agio_localecookie whose value is one oflocales. GioJS reads the cookie but never sets it: your app sets it, typically from a language switcher (below).
When no source matches, the locale is default_locale. A locale prefix is always removed from the path before routing, even when "path" is not in detect_from; an unknown first segment (/xyz/about) is left alone and routed as usual. A prefix for the default locale works too: /en/about renders the same page as /about.
Accept-Language comes before the cookie, so a visitor whose browser asks for German keeps getting German on unprefixed URLs even after choosing English in your switcher. To let a saved choice win, put "cookie" first after "path": detect_from = ["path", "cookie", "accept-language"].Reading the locale
| Where | How |
|---|---|
getServerSideProps, generateMetadata | ctx.locale |
| Page actions and route handlers | req.locale |
| Components (server render and browser) | useLocale() from @gio.js/react |
ctx.path, req.path and usePathname() are the path without the locale prefix. A page that loads its strings in getServerSideProps:
import React from 'react';
import type { GsspContext } from '@gio.js/core';
import { LocaleLink, useLocale } from '@gio.js/react';
const messages = {
en: { hello: 'Hello' },
de: { hello: 'Hallo' },
};
type Locale = keyof typeof messages;
export async function getServerSideProps(ctx: GsspContext) {
const locale = (ctx.locale ?? 'en') as Locale;
return { props: { hello: (messages[locale] ?? messages.en).hello } };
}
export default function Greeting({ hello }: { hello: string }): React.JSX.Element {
const locale = useLocale(); // 'de' on /de/greeting
return (
<main lang={locale}>
<h1>{hello}</h1>
<LocaleLink href="/contact">Contact</LocaleLink>{/* /de/contact on German pages */}
</main>
);
}<LocaleLink> prefixes its href with the current locale unless it is default_locale, already in the server HTML. Links to other sites and paths that already carry a locale prefix are left as they are.
A language switcher
Store the visitor's choice in the gio_locale cookie and send them to the page in that language. A page action does both, and works without JavaScript:
import { redirect, serializeCookie, type ActionArgs } from '@gio.js/core';
const LOCALES = ['en', 'de'];
export async function action(req: ActionArgs) {
const form = await req.formData();
const locale = String(form.get('locale'));
if (!LOCALES.includes(locale)) return { status: 422, data: { error: 'Unknown locale' } };
return redirect(locale === 'en' ? req.path : `/${locale}${req.path}`, {
headers: {
'set-cookie': serializeCookie('gio_locale', locale, { maxAge: 60 * 60 * 24 * 365 }),
},
});
}
// In the page component:
// <form method="post">
// <button name="locale" value="de">Deutsch</button>
// <button name="locale" value="en">English</button>
// </form>$ curl -si -X POST -d locale=de http://localhost:3000/greeting | grep -i 'location\|set-cookie'
location: /de/greeting
set-cookie: gio_locale=de; Max-Age=31536000; Path=/; HttpOnly; Secure; SameSite=LaxserializeCookie adds Secure in production only, so the cookie also works on http://localhost in development.
The html lang attribute
For a locale other than the default, the server sets lang="de" on the page's <html> tag, which browsers and screen readers use, replacing the lang the root layout wrote. Keep lang set to your default locale in the root layout; pages in the default locale keep it as written.
Caching and SEO
- The page cache keys every page by locale, so
/de/aboutand/aboutare separate entries. - A page whose locale came from the URL prefix can be shared by CDNs as usual. One whose locale came from
Accept-Languageor the cookie (an unprefixed URL whiledetect_fromlists them) serves different languages at one URL, so it is sent asCache-Control: private, no-cachewithout anETag. Link to prefixed URLs where CDN caching matters. - Purging a page with
revalidatePath('/de/about')or/_gio/revalidatepurges it in every locale: the locale segment is dropped from the path. - Tell search engines about the other languages with
metadataalternates.languages:app/about/page.tsxexport const metadata = { alternates: { canonical: '/about', languages: { en: '/about', de: '/de/about', fr: '/fr/about' }, }, }; - The
[i18n]section is part of the deployment ID: changing it drops the persisted page cache.
Good to know
- A redirect built from
ctx.pathloses the prefix: addctx.localeback when the target should stay in the visitor's language. - Guards, redirects, rewrites and header rules, rate limits and CSRF exemptions see the path after the prefix is removed: a guard on
/admin/*restalso covers/de/admin. Their redirect targets are used as written, so/de/blog/xthrough a/blog/:slugrule lands on the unprefixed target. - Static export has no server, so there is no detection and no prefix handling:
useLocale()returns''in an exported site.