GioJSdocs
On this page

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

gio.toml
[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
KeyTypeDefaultDescription
localesstring[][]The locales the app serves, as they appear in URLs. Empty: i18n is off.
default_localestring"en"The locale when nothing else matches. Its pages need no prefix.
detect_fromstring[]["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 of locales: /de/about is de. Matching is exact (/DE/about is not a locale prefix).
  • "accept-language" - the browser's language list. Entries are tried from the highest quality value (;q=, default 1) 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, so de-AT matches de. de;q=0.1, en picks en. An entry with q=0 ("not this language") or a malformed q is never picked. The locale is always spelled as in locales: pt-br in the header gives a configured pt-BR.
  • "cookie" - a gio_locale cookie whose value is one of locales. 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.

In the default order, 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

WhereHow
getServerSideProps, generateMetadatactx.locale
Page actions and route handlersreq.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:

app/greeting/page.tsx
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:

app/greeting/page.tsx
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>
text
$ 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=Lax

serializeCookie 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/about and /about are separate entries.
  • A page whose locale came from the URL prefix can be shared by CDNs as usual. One whose locale came from Accept-Language or the cookie (an unprefixed URL while detect_from lists them) serves different languages at one URL, so it is sent as Cache-Control: private, no-cache without an ETag. Link to prefixed URLs where CDN caching matters.
  • Purging a page with revalidatePath('/de/about') or /_gio/revalidate purges it in every locale: the locale segment is dropped from the path.
  • Tell search engines about the other languages with metadata alternates.languages:
    app/about/page.tsx
    export 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.path loses the prefix: add ctx.locale back 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/*rest also covers /de/admin. Their redirect targets are used as written, so /de/blog/x through a /blog/:slug rule 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.