One app, many languages
Localization (i18n)
Translation catalogs, plural selection, locale negotiation, a session-remembered language choice, and optional
/es/... URL prefixes. Localization stays off until src/locales/
contains a <locale>.json file or APP_LOCALES is set — until then
t("key") returns the key.
Translation files
{
"greeting": "Hello, :name!",
"nav": { "home": "Home", "orders": "Orders" },
"cart": {
"items": { "zero": "Your cart is empty", "one": "One item", "other": ":count items" }
}
}
{ "greeting": "¡Hola, :name!", "nav": { "home": "Inicio", "orders": "Pedidos" }, "cart": { "items": { "one": "Un artículo", "other": ":count artículos" } } }
-
Keys nest with dots (
nav.home); a literal key containing dots also works. -
Placeholders are
:name, never a brace form, so translated text can never become a PulsePoint binding. -
Plural objects (
zero,one,two,few,many,other) are chosen bycount;otheris required. These are simple count rules, not full CLDR. -
A missing key falls back to the base language (
es-MX→es), then the default locale, then the key, logging one warning. Files reload when they change.
Using translations
In templates, t, current_locale, and locale_url are globals. Values are server-rendered and escaped like any other.
<html lang="{{ current_locale() }}">
<p>{{ t("greeting", name=user_name) }}</p>
<p>{{ t("cart.items", count=item_count) }}</p>
<a href="{{ locale_url('/orders') }}">{{ t("nav.orders") }}</a>
In Python
from casp.i18n import current_locale, t
from casp.layout import Metadata
async def page():
Metadata(title=t("nav.orders"))
...
In a component script
<script>
const [labels] = pp.state({{ messages("cart") | json }});
</script>
Pass a catalog subtree through the json filter into state.
How the locale is chosen
- 01A URL prefix such as
/es/orders, only whenI18N_PREFIX_ROUTING=true. - 02The choice saved in the session by
set_locale(...). - 03The
Accept-Languageheader, respecting quality values. - 04The default locale.
The result is current_locale() and request.state.locale.
Switching language
from casp.i18n import set_locale
from casp.rpc import rpc
@rpc()
async def change_language(locale: str):
set_locale(locale) # raises ValueError for an unsupported locale
return {"locale": locale}
Reload the page after the call (location.reload()) so the new language renders.
Locale-prefixed URLs
With I18N_PREFIX_ROUTING=true, /es/orders renders
src/app/orders/index.py in Spanish. The prefix is removed before routing, so there is
no [locale] folder. The default locale is served bare
(/orders), and /en/orders also works.
-
Build links with
locale_url(path); it keeps the default locale bare and leaves external URLs untouched. -
Route privacy, RPC route keys, and
request.url.pathall see the path without the prefix. -
Avoid top-level route folders named like a locale (
src/app/de) — they would be read as a prefix. -
Public files, probes, and maintenance
--allowsubtrees match the path as requested.
Boundaries
- The page cache stores one document per language;
revalidate_path("/orders")clears every language. - Error pages for failures outside a page handler use the default locale.
- Static export renders each route once, in the default locale.
- Dates and numbers are not formatted per locale — format them in your own helpers.
| Variable | Default |
|---|---|
| APP_LOCALES | JSON files in src/locales |
| APP_DEFAULT_LOCALE | the first locale |
| I18N_PREFIX_ROUTING | off |
| I18N_DIRECTORY | src/locales |