Localization
Theme translations — locales/*.json in the kit, the store_template_locales storage, and the t() pipeline with fallback rules.
Theme copy — "Buy it now", "Your cart is empty", checkout headings — ships with the
theme as one JSON file per language. The build embeds them into the template, publish
moves them to a dedicated backend table, and at runtime every custom component reads them
through one function: t().
This is separate from the platform's own chrome strings (native order form labels, validation messages), which Lithium translates internally. Keep theme copy in the theme: that is what makes a published template work on any store in any supported language.
The pipeline
locales/{ar,en,fr}.json # you author these in the kit
│ feeef template build
▼
data.i18n = { defaultLocale, locales, messages } # embedded for local dev
│ feeef template publish
▼
store_template_locales # one backend row per language
│ SSR fetch on the storefront
▼
t("checkout.title") # in custom JSX, resolved per visitorAuthoring locale files
my-template/
locales/
ar.json
en.json
fr.json
feeef.template.json # optional "defaultLocale": "en"
pages/...Nested objects, dotted lookup, simple named parameters:
{
"checkout": {
"title": "Checkout",
"empty": "Your cart is empty",
"itemsInCart": "{count} items in cart"
},
"thanks": {
"title": "Thank you!",
"subtitle": "Your order was received"
},
"common": {
"addToCart": "Add to cart",
"buyNow": "Buy it now"
}
}Interpolation is a plain {param} replacement — t("checkout.itemsInCart", { count: 3 })
— not full ICU (no plural rules or select clauses).
Compiled and stored shapes
feeef template build embeds the bundle into the TemplateData and also writes
dist/locales/*.json for upload:
{
"props": { "theme": { "mode": "light" } },
"i18n": {
"defaultLocale": "en",
"locales": ["ar", "en", "fr"],
"messages": {
"ar": { "checkout": { "title": "إتمام الشراء" } },
"en": { "checkout": { "title": "Checkout" } }
}
},
"pages": {}
}Canonical storage after publishing is the store_template_locales table — one row
per language on the listing, not a blob on the template. Publish strips data.i18n and
replaces the rows from dist/locales/*.json.
| Method | Path | Purpose |
|---|---|---|
GET | /store_templates/:id/locales | Public, cached bundle { defaultLocale, locales, messages } |
PUT | /store_templates/:id/locales | Replace the full set — what the CLI publish calls |
POST | /store_templates/:id/locales | Add one locale row |
PUT | /store_templates/:id/locales/:locale | Update one locale's messages / default flag |
DELETE | /store_templates/:id/locales/:locale | Remove a locale |
Exactly one row is the default; if none is flagged on a full replace, the first locale (alphabetically) becomes it. Mutations require template-owner auth; the GET is public.
On the storefront, when a store has a templateId, the locales bundle is fetched
server-side (revalidated every 60 seconds) into the effective data.i18n. Themes still
carrying embedded data.i18n (legacy installs, local kit builds, draft previews) fall
back to it seamlessly.
Which language does the visitor see?
The active locale is derived from the store's language setting, not the browser:
store.configs.defaultLanguage # "ar", "en", "fr", "ar-DZ", "arabic", ...
→ normalized to a supported locale: direct match → region prefix ("ar-DZ" → "ar")
→ aliases ("arabic", "français", ...) → fallback "ar"The same mapping drives the HTML lang/dir and the platform chrome, so theme strings
and native strings always agree. On the client, a ?hl= query parameter (persisted as
feeef_hl in localStorage) can override the store default — useful for previewing a
locale.
Two "defaults" coexist and mean different things:
| Concept | Field | Role |
|---|---|---|
| Store active language | store.configs.defaultLanguage | Which locale visitors see |
| Theme fallback locale | i18n.defaultLocale | Where t() looks when a key is missing |
Fallback rules
t(key) resolves in strict order and never throws:
messages[activeLocale]— the visitor's localemessages[defaultLocale]— the theme's fallback locale- The key string itself (
"checkout.title") — so a missing translation is visible, not a crash
Example: theme defaultLocale: "en", store language ar — t() reads Arabic first and
falls back to English per key.
Using it in custom components
t, useFeeefT and useFeeefLocale are injected into the
custom component scope — no imports:
function App() {
const title = t("checkout.title");
const meta = t("checkout.itemsInCart", { count: 3 });
const translate = useFeeefT(); // hook form, same resolver
const locale = useFeeefLocale(); // "ar" | "en" | "fr"
const dir = locale === "ar" ? "rtl" : "ltr";
return (
<section dir={dir}>
<h1>{title}</h1>
<p>{meta}</p>
</section>
);
}| Symbol | Meaning |
|---|---|
t(key, params?) | Theme locale lookup with {param} interpolation |
useFeeefT() | Same resolver as a hook (memoized per template + locale) |
useFeeefLocale() | Active locale code |
Never hardcode bilingual fallbacks in JSX (isArabic ? "اشتر الآن" : "Buy now") — add
the key to every locale file instead.
Marketplace components
template_components rows carry code and schemas only — no language packs. When a
merchant places a public component from another author, t() inside it resolves against
the host store's theme bundle. Missing keys fall back per the rules above, so prefix
your keys (myTheme.gallery.zoom) to avoid collisions, and document any keys a reusable
component expects.
RTL and LTR
Arabic stores render right-to-left; a portable theme handles both directions:
- Derive direction from the mapped locale (
aris RTL) and setdiron each chrome root — header, footer, product shell, checkout shell. - Use logical CSS properties:
margin-inline-start,padding-inline,inset-inline-start,text-align: start— never left/right pairs. - Phone number inputs keep their digits LTR (
dir="ltr",text-align: lefton the input) while the label, placeholder and errors follow the theme direction. - Never hardcode
dir="ltr"ordirection: ltron containers in a multilang theme.
Roadmap
A planned phase lets props hold a key instead of a literal — either
{ "title": { "$t": "checkout.title" } } or the compact "@i18n:checkout.title" —
resolved to the active locale at render. Today, translate inside the component with
t(); plain string props always win as merchant overrides.
store.configs.languages[] (the merchant's enabled-language list) and per-component
propsLocales editor tabs are reserved for a future visitor-facing language switcher.
Currently only defaultLanguage drives the storefront.
Pitfalls
| Bad | Good |
|---|---|
| Hardcoded copy or bilingual ternaries in JSX | t("...") + a key in every locale file |
| Theme strings added to the platform's message files | locales/*.json in the theme — keeps it portable |
Translations nested under props.i18n ad hoc | Top-level data.i18n via the kit build |
| Assuming English is the fallback | The theme's i18n.defaultLocale decides; platform default is Arabic |
| Full ICU plural syntax in messages | Simple {param} placeholders |
| Left/right CSS in RTL-capable themes | Logical properties + dir from the locale |
Related: template kit for the build and publish flow, custom components for the injected scope, and standard pages for translating checkout and thanks copy.