Skip to content
Twilight React Playground
ثيم رائدaren

Theme translations

typeBeginnerserverbrowserlive demo

How a theme's locales/*.json files reach t(): the Vite plugin bundles them, TwilightProvider receives them, and Salla's messages are checked first.

import { TranslationMessages } from '@salla.sa/twilight-theme-engine/i18n';

In plain words

Salla already translates the common storefront words, such as "Cart" and "Add to cart". For the words only your theme uses, put one JSON file per language in a locales folder: locales/ar.json, locales/en.json.

Three lines connect those files to t():

  1. vite.config.ts names the folder: twilightReact({ localesDir: './locales' }).
  2. The build turns the folder into a module you import as virtual:twilight/theme-translations. "Virtual" means it is generated while the theme builds and never exists as a file.
  3. app/routes/__root.tsx hands that module to the provider: <TwilightProvider translations={themeTranslations}>.

After that, t('your.key') finds your text whenever Salla has none for the same key. TranslationMessages is the TypeScript type of the module: one object per language, under keys such as 'ar.trans'.

Signature

type TranslationMessages = Record<string, Record<string, string>>;

// virtual:twilight/theme-translations, generated from locales/*.json:
// { 'ar.trans': { lookbook: { title: '…' } }, 'en.trans': { lookbook: { title: 'Lookbook' } } }

// vite.config.ts: no default, so without it the module is {}
twilightReact({ localesDir?: string })

// app/routes/__root.tsx
<TwilightProvider translations={themeTranslations}>

Find a translation key

1,396 keys. Start with a word you would show a shopper, or open a group:

Try it live

Where a key is found: Salla's app namespace first, then your theme files. Below, the module the build made from packages/playground/locales.Try this: type common.titles.cart (only Salla has it), then playground.items, then just playground.
Storefront canvas · en · LTR
app: Salla's messages(not here)
theme: locales/en.jsonHello from the theme's own translations
t() answersHello from the theme's own translations
virtual:twilight/theme-translations: {…} 2 keys
ar.trans: {…} 1 keys
playground: {…} 2 keys
greeting: "مرحبًا من ترجمات القالب نفسه"
items: "{0} لا عناصر|{1} عنصر واحد|[2,*] :count عناصر"
en.trans: {…} 1 keys
playground: {…} 2 keys
greeting: "Hello from the theme's own translations"
items: "{0} no items|{1} one item|[2,*] :count items"
Controls
What a theme writes
// locales/ar.json and locales/en.json: { "playground": { "greeting": "…" } }
// vite.config.ts: twilightReact({ localesDir: './locales' })
// app/routes/__root.tsx: <TwilightProvider translations={themeTranslations}>
import { useTranslation } from '@salla.sa/twilight-theme-engine/i18n';

export function Label() {
  const { t } = useTranslation();
  return <span>{t('playground.greeting')}</span>;
}

Example

app/components/LookbookTitle.tsx
// locales/en.json   { "lookbook": { "title": "Lookbook" } }
// locales/ar.json   { "lookbook": { "title": "دليل الإطلالات" } }
// vite.config.ts    twilightReact({ localesDir: './locales' })
// __root.tsx        import themeTranslations from 'virtual:twilight/theme-translations';
//                   <TwilightProvider translations={themeTranslations}>
import { useTranslation } from '@salla.sa/twilight-theme-engine/i18n';

export function LookbookTitle() {
  const { t } = useTranslation();
  return <h1>{t('lookbook.title', 'Lookbook')}</h1>;
}

How it behaves

  • The plugin (twilight:translations) reads every *.json directly inside localesDir, not in subfolders. The file name is the language: en.json becomes 'en.trans'. The module is generated in memory; nothing is written to disk.

  • TwilightProvider passes the object to I18nProvider, which merges only the page language's part (themeTranslations['<locale>.trans']) into the i18next theme namespace, deep and overwriting, and only when that part has keys.

  • theme is the fallback namespace: t(key) looks in Salla's messages (app) first. t(key, { ns: 'theme' }) reads your file alone.

  • Nested objects and flat dotted keys both work. The playground's own locales/*.json are nested; docs/getting-started/08-add-translations.md asks for flat keys, which is a style choice, not a requirement.

  • The module is declared as Record<string, Record<string, string>> in the engine's dist/ambient/virtual-modules.d.ts, which a theme lists under files in tsconfig.json. Nested files do not match that type, and work at runtime all the same.

  • In the dev server the plugin watches the folder and invalidates the module when a *.json file in it changes or is added.

Gotchas

  • One invalid file empties the module for every language. The plugin wraps the whole read in try/catch and returns export default {} without logging anything (theme-translations.plugin.ts), so a trailing comma in en.json makes every theme-only key render its default or its key, in Arabic too. Fix: when theme texts vanish, check each file parses, e.g. node -e "require('./locales/en.json')", which names the broken position.

  • Forgetting either end fails silently. Without localesDir the module is {}; without the translations prop I18nProvider has nothing to merge. Nothing warns, and t returns defaults or keys. Fix: keep both, as packages/theme-custom/vite.config.ts and app/routes/__root.tsx do.

  • A key Salla also has never shows your text, because app is searched before theme. Fix: give your keys a prefix Salla does not use (the playground uses playground.*), or read your file explicitly with t(key, { ns: 'theme' }).

  • Only the instance inside the provider has your texts. rootBeforeLoad builds a new instance with createI18nInstance(locale, translations), which receives Salla's messages only, and loaders and head functions run before the provider renders; each client-side navigation builds another. So getTwilightContext().i18n.t('lookbook.title') in a loader returns the key, and so does useTwilight().i18n.t('lookbook.title') in a component after the first client-side navigation. Fix: use useTranslation() in components; in loaders and head, pass the text as the default (i18n.t('lookbook.title', 'Lookbook')) or first merge your file the way the provider does: i18n.addResourceBundle(locale, 'theme', themeTranslations[locale + '.trans'] ?? {}, true, true).

Related

Source and docs