Skip to content

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"
Note

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 , dvdir="rtl"

  • az-Arabdir="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.

Tip

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 hreflang at all. A lone one pointing at itself says nothing.

  • x-default points 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.

Warning

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 itselffranç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.

Tip

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.

Warning

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.