Localisation

Which locales the site serves, how the locale reaches every address, how to add one, and where translation is resolved.

2026-11-07

Locales and RTL

Localisation on the site has two moving parts that meet in @v4/model: the set of locales an environment serves, and the dictionary of UI copy those locales translate (covered in the Dictionary keys guide). This page is about the locales themselves and the current position on right-to-left layout.

Two lists of locales

There are two lists, and they are not the same thing:

  • the dictionary locales — the locales the Contentful space holds labels for, generated from the space; and
  • the served locales — the locales the site answers on, declared in club.config.ts.

The dictionary locales

Locales are environment-scoped Contentful configuration. The committed list lives in the generated dictionary map and is the source of truth the code compiles against:

// packages/model/src/__generated__/dictionary/index.ts — generated, do not edit
export const DICTIONARY_LOCALES = ["en-GB", "en-US", "fr-FR", "it-IT"] as const;
export const DEFAULT_DICTIONARY_LOCALE = "en-US" as const;

en-US is the default and the fallback: a key resolves from the requested locale, then en-US, then the default copy in keys.ts. Every consumer of getDictionary(locale) (and the synchronous createDictionary(locale) behind it) is typed against DICTIONARY_LOCALES, so a locale that is not in that array is a compile error, never a runtime surprise.

One consequence catches people out: a seeded en-US label beats the default copy in keys.ts. So the site's copy can differ from the design — and from the vocabulary file — with nothing wrong in the code; the label in Contentful is what wins.

Only en-US carries labels in this space (12gd2cvfgb6b). The generated en-GB.ts, fr-FR.ts and it-IT.ts maps are empty (labels = {}); en-US.ts holds every label. Translation is an editorial task carried out in Contentful, not in code — re-pull with sync-dictionary before relying on any coverage.

The served locales

The site serves one locale:

// apps/web/club.config.ts
locales: { supported: ["en-US"], default: "en-US", routing: "segment" },

routing: "segment" puts the locale's segment in every page address — /en/news, /en/teams/euroleague. pnpm compose generates the segment map as apps/web/src/lib/locale-routing.generated.ts (LOCALE_SEGMENTS = { "en-US": "en" }, DEFAULT_LOCALE_SEGMENT = "en"), and:

  • every route lives under apps/web/src/app/[locale]/;
  • apps/web/src/middleware.ts sends an address with no supported segment to the same page in the default's (/news → /en/news, a 308);
  • apps/web/src/lib/locale-routing.ts holds the app's helpers — withLocale, withoutLocale, localisedAddress, HOME_PATH;
  • localePath in @v4/model/core turns a site path into the address the browser uses (/news → /en/news) wherever a path leaves for the browser: a link's href, a redirect, a canonical, a sitemap entry. Code that READS a path works without the segment.

Adding a locale

Locales are Contentful configuration that contentful-migration does not manage, so a dedicated CMA script adds them. This is the mechanical half of the /model-locale skill:

node packages/model/scripts/add-locale.mjs \
  --code=es-ES --name="Spanish (Spain)" [--fallback=en-US] [--optional] [--env=develop]

--code and --name are required. --fallback defaults to en-GB in the script, which is not this space's default — pass --fallback=en-US. --env defaults to develop (or CONTENTFUL_MODEL_ENV). The script shares the same protected-environment guard as the migration runner — it refuses master, production, staging and staging-release unless it is running in CI with the environment explicitly unlocked. It authenticates with CONTENTFUL_CMA_TOKEN (read from the environment or the root .env).

Adding a locale changes both the vendored GraphQL schema and the dictionary maps, so it is not a one-command job. After running add-locale.mjs, complete the sequence:

pnpm --filter @v4/model sync-schema        # the schema gains the new locale
pnpm --filter @v4/model sync-dictionary    # regenerate the per-locale maps
pnpm --filter @v4/model generate           # regenerate types

Then update baseline/locales.json from a fresh export, and expect every localised field across the space to need content — a new locale starts empty and falls back to en-US everywhere until editors translate it.

That adds a dictionary locale. For the site to serve it, add it to locales.supported in apps/web/club.config.ts and run pnpm compose to regenerate the segment map. (One served locale is what the code assumes today: localePath holds a single prefix, and is the seam a second locale would change.)

The RTL position

The token layer anticipates right-to-left languages: tokens.json ships a complete P.RTL.* typography ramp and an fontFamilies.rtl entry. The components do not yet consume them. Every Ghost 2.0 component set is authored LTR-only — 93 component manifests bind P.LTR.*, and the single P.RTL.* binding (on one form-input-field node) is an outlier rather than a pattern.

Because Ghost 2.0 has no RTL artboards, mirroring behaviour is an engineering decision made against the WAI/W3C i18n guidance, not a design hand-off. Two mechanisms already carry that decision:

  • Logical properties everywhere. Components use marginInlineStart, paddingInlineEnd, insetInlineStart, borderStartStartRadius and so on from day one, so the layout mirrors under dir="rtl" for free.
  • directionalIcon for the glyphs that mean direction. Logical properties mirror layout but not artwork — an arrow drawn pointing right keeps pointing right on an RTL page. The one house mechanism for flipping inline-axis navigation glyphs (next/back paddles, drill-in chevrons) is the opt-in directionalIcon utility exported from @v4/ghost-core, applied per icon slot with data-directional="true". It is never blanket — logos, play triangles, ticks and block-axis chevrons must not flip.

The full rules live in .claude/rules/frontend/vanilla-extract-conventions.md under "RTL: Logical Properties from Day One" and "Directional glyphs". Revisit the whole position when design ships RTL artboards.

Server-first resolution

The house pattern for translated copy is resolve on the server, pass strings to the client. It keeps the per-locale label maps out of client bundles.

Where translation happens

Resolve copy in React Server Components and in transform code — the same layer that turns @v4/model fragments into component props — and hand the resulting plain strings down as props to client components:

import { getDictionary } from "@v4/model/dictionary";

// in an RSC or a route factory (server code)
const t = await getDictionary(locale);

const heading = t("article.label.relatedContent");
const readTime = t("article.Label.estimatedReadingTime");

// pass the resolved STRINGS as props — never the dictionary itself
return <RelatedContent heading={heading} readTimeLabel={readTime} />;

getDictionary(locale), from the server-only @v4/model/dictionary subpath, reads the live labels from Contentful through the client the app configured once (configureDictionary({ client }), in apps/web/src/lib/content.ts). The read is cached and tagged content:dictionaryKey, so an editor's published label reaches the site without a deploy. The 404 page is a worked example (apps/web/src/app/[locale]/not-found.tsx).

createDictionary(locale) from @v4/model is the synchronous snapshot over the committed generated maps. getDictionary falls back to it when no client is configured or the fetch fails — copy is never worth a 500 — and tests use it directly.

Both return the same typed function. Resolution order per key is requested locale → default locale (en-US) → the vocabulary's default copy in keys.ts, so a key always resolves to something — a missing translation degrades to English rather than rendering an empty string or a raw key.

Why not resolve in the client

The generated snapshot maps are sizeable — en-US.ts, the only populated one in this space, is about 46 KB (the en-GB, fr-FR and it-IT maps are empty). They only enter a client bundle if a client module imports createDictionary directly; getDictionary cannot be imported there at all (it is server-only). Resolving server-side means:

  • the client ships the handful of strings a component actually renders, not the whole dictionary for its locale;
  • the locale decision is made once, at the top of the render, close to where the request's locale is known;
  • Server Components and transforms are already the layer that shapes model data into props, so translation slots in without a new seam.

Treat a client-component import of createDictionary as a smell: it usually means a string that should have been resolved a layer up.

Interpolation is typed

Keys whose default copy carries a placeholder require a matching params object; keys with no placeholder refuse one — both checked at compile time, because keys.ts is as const and the compiler reads each default label as a literal type:

const t = createDictionary("en-US");

// default copy: "Would you like to update to version {{version}} ?"
t("appVersion.update.description", { version: "4.2.0" }); // ok
t("appVersion.update.description"); // type error — params required

// a key with no placeholder refuses a params object
t("common.action.signIn", { foo: "bar" }); // type error

Both {name} and the legacy {{name}} placeholder forms are recognised (both appear in the seeded copy). For namespaced access, t.scope("appMoreMenu.action") returns a scoped accessor whose keys are the suffixes under that prefix — the params typing carries through it unchanged. See the Consuming section of the dictionary guide for more.