Locales and languages
A multi-language Jx site is a directory per language:
pages/
├── index.json → /
├── about.json → /about/
├── fr-ca/
│ ├── index.json → /fr-ca/
│ └── about.json → /fr-ca/about/
└── ar/
└── index.json → /ar/
Declare the locales in
project.json
and the build reads the prefixes:
{
"i18n": {
"defaultLocale": "en",
"locales": ["en", "fr-ca", "ar"],
"routing": "prefix-except-default"
}
}
Each page then ships with the right
<html lang>
:
| Route | <html>
attributes |
|---|---|
/about/ |
lang="en" |
/fr-ca/about/ |
lang="fr-CA" |
/ar/ |
lang="ar" dir="rtl" |
Jx is
not
a translation system. There's no message catalogue, no
t()
, no fallback chain. A locale is a property of a route, and what the route serves is whatever you put in that directory. If you want the same page in three languages, you write three pages.
Tags are checked
Locales are BCP 47 language tags, and a malformed one fails the build:
i18n.locales: "en_US" is not a well-formed BCP 47 language tag.
That's an error rather than a warning because a locale is three things at once — a URL prefix, an
<html lang>
, and later an
hreflang
value. A typo doesn't degrade into something slightly worse; it ships pages claiming a language that doesn't exist, and nothing downstream can tell.
jx validate
catches the same typo, so you don't have to run a build to hear about it:
/i18n/locales/0: must match pattern "^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$"
The schema's pattern is looser than the build's parse on purpose — it knows about hyphens and subtag lengths, not about subtag order or the IANA registry. It will never reject a tag the build accepts, so a project that compiles always validates. Everything past shape stays with the build.
Tags are also
canonicalized
: write
EN-us
and you get
en-US
, everywhere. That matters because the tag gets compared as a string against directory names.
What isn't checked is whether the language exists.
zz
and
xx-YY
are perfectly well-formed tags, and Jx doesn't carry a copy of the IANA registry to tell you otherwise.
Directory names must match
The prefix is matched against your declared locales, case-insensitively but completely :
| You declared | Directory | Result |
|---|---|---|
fr-ca |
pages/fr-ca/ |
✅
lang="fr-CA" |
fr-ca |
pages/FR-CA/ |
✅
lang="fr-CA" |
fr-ca |
pages/fr/ |
⚠️ default locale, with a warning |
That last row is the mistake worth knowing about, and the build tells you:
Routes under /fr/ are served as "en" — i18n.locales declares "fr-CA", not "fr".
Rename the directory to "fr-ca" or declare the shorter tag.
The warning only fires when a directory names the
language
of a locale you declared. It doesn't guess about
/docs/
or
/api/
— any two-to-eight-letter segment is technically a well-formed language tag, so a broader check would cry wolf on ordinary paths.
Overriding a page
A page can state its own language, and it wins over the directory it sits in:
{
"$lang": "fr-CA",
"$dir": "auto",
"tagName": "main"
}
A French page at
/en/a-propos/
is a real thing, and if you wrote
$lang
down you meant it.
Direction comes for free
You never write
dir="rtl"
. The build works it out from the
script
of the language tag:
ar,he,fa,ur,ckb,yi,dv→dir="rtl"az-Arab→dir="rtl"(Azerbaijani written in Arabic script)az,en,zh-Hant→ nothing
dir="ltr"
is never emitted, because it's already HTML's default for every element — writing it on every page would say nothing.
Direction follows the script, not the language. That's why
az-Arab
is right-to-left while
az
isn't, and it's why
dv
works even though some ICU builds report Dhivehi as left-to-right.
Reading the locale in a template
{ "tagName": "p", "textContent": "Reading in en" }
| Expression | Is |
|---|---|
$page.locale |
the page's resolved tag, e.g.
fr-CA |
$page.dir |
"ltr"
or
"rtl" |
$page.alternates |
this page in every language it exists in |
$site.defaultLocale |
your
defaultLocale
, canonicalized |
$site.locales |
every declared locale, in order |
$page.locale
is the
resolved
answer — after a page's own
$lang
has had its say — not the URL prefix.
Translations advertise each other
Pages that are translations of one another say so, in
<head>
:
<link href="https://example.com/about/" hreflang="en" rel="alternate" />
<link href="https://example.com/fr-ca/about/" hreflang="fr-CA" rel="alternate" />
<link href="https://example.com/about/" hreflang="x-default" rel="alternate" />
and in
sitemap.xml
, as
xhtml:link
entries inside each
<url>
. You don't configure any of it — set
url
in
project.json
so the build can make absolute URLs, and it follows.
Two pages are translations when their paths match after the locale prefix is removed.
/fr-ca/about/
and
/about/
both reduce to
about
, so they're a set. That's the default mapping, and for parallel paths it's the whole story.
A few details that follow from what
hreflang
actually means:
Every page in a set lists the whole set, including itself . That's the specified behaviour, and validators check for it.
A page with no translations gets no
hreflangat all. A lone one pointing at itself says nothing.x-defaultpoints at your default locale — the page to send a visitor whose language you don't have. It's omitted if the set has no default-locale page.Write your own
<link rel="alternate" hreflang="...">and yours wins.
Translated URLs
/fr-ca/a-propos/
shares nothing with
/about/
, so the page says what it is:
{ "$translationKey": "about", "title": "À propos" }
in
pages/fr-ca/a-propos.json
. That's the only key involved —
hreflang
,
x-default
, the sitemap and the language switcher all follow from it, because they were all reading the same grouping already.
$translationKey
overrides the key your path implies, exactly as
$lang
overrides the language it implies.
Write the key the way the URL reads if you like;
"/about/"
and
"about"
are the same key.
Two pages can't be the same language of the same page.
A set names one URL per language, so if
/fr-ca/a-propos/
and
/fr-ca/about/
both claim
about
, one is dropped from it. When both said so with
$translationKey
, that's a build error — you wrote the promise down twice, and silently advertising one of them as
the
French page is a wrong answer nobody can see. When the paths collided on their own (
pages/about.json
beside
pages/en/about.json
), it's only a warning: you might mean it.
A collection's URLs can be translated too — the key just has to vary per entry, so it names the route's own parameter:
{
"$translationKey": ,
"$paths": { "contentType": "exhibitions", "param": "slug" }
}
in
pages/fr-ca/expositions/[slug].json
. Each French post pairs with the English one, because
two translations share an id
and the id is what
carries. Without the parameter the whole collection would claim one identity, and the build would tell you so.
A language switcher
$page.alternates
is the same translation set the
<head>
links describe, in the shape a template can render — an array of
{ code, label, url, dir, current }
, ordered by tag, with the page itself included:
{
"tagName": "nav",
"children": {
"$prototype": "Array",
"items": { "$ref": "#/state/$page/alternates" },
"map": {
"tagName": "a",
"dir": ,
"attributes": { "href": , "hreflang": },
"textContent":
}
}
}
That is the whole switcher. It gains a language when a page gains a translation, and loses one when a page loses it, because it is the route table rather than a list you maintain.
| Field | Is |
|---|---|
code |
the locale's canonical tag |
label |
that language's name
in itself
—
français canadien |
url |
site-absolute —
/fr-ca/about
, not an absolute URL |
dir |
that language's direction, for an RTL option in an LTR menu |
current |
true
for the page the reader is on |
Three small differences from the
<head>
annotation, each following from who reads it: the URLs are site-absolute (a switcher is an internal link, and it has to work before you've set
url
), a page with no translations still gets
itself
(so the switcher can mark where the reader is), and there's no
x-default
— it names no language anyone could pick.
label
is the
autonym
— each language's name for itself, from CLDR. That's what a reader scans a switcher for; a menu that reads "French" is unreadable to exactly the person it exists for. Ignore the field and write your own text if you'd rather.
Numbers and dates follow the page
A formula that formats something and names no language uses the page's :
{
"operator": "call",
"target": { "$ref": "window#/Intl/formatNumber" },
"value": [{ "$ref": "#/state/price" }]
}
1,234.5
under
/about/
,
1 234,5
under
/fr-ca/about/
,
١٬٢٣٤٫٥
under
/ar/
. Nothing in the document says which — the route already did, in
<html lang>
and in every
hreflang
on the page.
Pass a locale explicitly and yours wins. Inside a
component
, there is no page in scope — a component's state is its own — so a formula there falls back to
en-US
unless you pass the locale in as a prop.
Sending a visitor to their language
A visitor landing on
/
can be sent to the language they actually read, from the
Accept-Language
header their browser already sends.
This needs
build.adapter
set. Without an adapter your site is files, and files never see a request — there is no runtime to read the header, so
/
serves whatever
/
holds. That isn't a missing feature; it's what a static deployment is.
Under
"routing": "prefix-always"
with no adapter, that leaves nothing at
/
at all, and the build says so:
i18n.routing is "prefix-always" and no page claims "/", so the site root is a 404.
Locale negotiation answers "/" only when build.adapter is set — a static deployment
has no runtime to read Accept-Language. Add a redirect from "/" to "/en/", set
build.adapter, or use "prefix-except-default".
A warning, not a generated redirect: which URL your root should serve — a redirect, a language-choice page, a rewrite at your CDN — is your decision, and the compiler can't see the reason for it.
With an adapter, the generated worker handles
/
:
| Visitor sends | Site offers
en
,
fr
,
de |
Result |
|---|---|---|
| nothing | — | your default locale |
fr |
fr |
redirect to
/fr/ |
fr-CA |
fr
(no
fr-CA
) |
redirect to
/fr/ |
en;q=0.1, de;q=0.9 |
both | redirect to
/de/
—
q
wins |
de;q=0, fr |
both | redirect to
/fr/
—
q=0
is a refusal |
ja, ko |
neither | your default locale |
Only
/
is negotiated. Someone who asked for
/fr/about/
has told you far more than a header does, and rewriting that would make a shared link mean different things to different people. Nothing is ever guessed from an IP address.
Every
/
response carries
Vary: Accept-Language
, so a CDN caches one copy per language instead of pinning everyone to whichever visitor arrived first. Redirects are
302
, never
301
— the destination depends on who's asking.
Content in one directory per locale
A collection can keep each language in its own directory:
{
"content": {
"blog": { "format": "Markdown", "source": "./content/blog/{locale}/" }
}
}
content/blog/
├── en/hello-world.md
└── fr/hello-world.md
That's
one
collection, not two: one schema, one name in
$paths
, one set of relationship targets. The build loads each locale's directory and remembers which one an entry came from.
The directory's case doesn't have to match your tag exactly —
fr-CA/
and
fr-ca/
are both found — because Studio writes the lowercase form (a locale directory becomes a URL segment) and you may well have typed the other.
That last part matters more than it sounds. Both files above are
hello-world
, so a
[slug]
route that expanded the whole collection would build that URL twice and keep whichever came second. Instead,
/fr/blog/[slug].json
expands the French entries and
/en/blog/[slug].json
the English ones — and a post that exists in only one language gets a route in only that language.
Images beside the entries are published per locale too, at
/content/blog/en/…
and
/content/blog/fr/…
, so two translations'
./hero.png
can't collide.
Looking one entry up by id — a
ContentEntry
on a detail page — is scoped the same way: a page under
/fr-ca/
gets the French entry. Without that it would get whichever translation loaded first, which is the English one, on a page that says it's French.
If you use
{locale}
without declaring any locales, nothing loads and the build tells you why.
prefix-always
is checked
"routing": "prefix-always"
says every URL names its language. A page outside the locale tree contradicts that, so the build points it out:
i18n.routing is "prefix-always", but 1 route(s) sit outside the locale tree and are
served as "en": /stray. Move them under a locale directory, or use
"prefix-except-default".
It's a warning, not an error — you might mean it, and the page still builds.
/
is never flagged: under
prefix-always
it exists to send visitors somewhere else.
What isn't built yet
No message catalogue. Jx has no
t()and no fallback chain, by design — see the top of this page.A component doesn't inherit the page's locale. Its state is its own; pass the locale in as a prop if a formula inside it needs to format.
Related
SEO and metadata — the rest of what goes in
<head>Routing — how directories become URLs