Three separate translation surfaces:
| What | Mechanism | Where it lives |
|---|---|---|
| Merchant content (product names, descriptions, slugs, category names…) | Mobility translation tables | spree_<model>_translations tables; edited in the dashboard or via the Admin API |
| Backend strings (emails, validation errors, API error messages) | Rails I18n + Spree.t |
config/locales/<locale>.yml; community spree_i18n gem |
| Dashboard UI (the React admin) | i18next JSON bundles | @spree/dashboard ships ar de en es fr pl zh-CN; plugins register their own |
Which locales a store offers is decided by its Markets (each market has default_locale + supported_locales) — see spree-data-model / markets docs.
Locale resolution (Store API)
Per request, Spree::Api::V3::LocaleAndCurrency:
- Resolves the market from
X-Spree-Country. - Locale =
X-Spree-Localeheader →?locale=param (each honored only if incurrent_store.supported_locales_list) →Spree::Current.locale(market default → store default). Unsupported values silently fall back. - Sets
I18n.locale,Spree::Current.locale, andSpree::Current.content_locale = current_store.default_locale. - Configures Mobility fallbacks via
Spree::Locales::SetFallbackLocaleForStore— every locale falls back to the store's default locale.
const client = createClient({ baseUrl, publishableKey, locale: 'fr' })
client.setLocale('de')
await client.products.get('sac-spree', {}, { locale: 'fr' }) // per-request override
Pass exactly a code the store supports (es, not es-ES unless configured). Storefront language switchers read GET /api/v3/store/locales.
Caching: guest Store API responses set Vary: Accept, x-spree-currency, x-spree-locale, x-spree-channel; authenticated responses are private, no-store. The Vary list does not include x-spree-country, yet country selects the market (locale/currency fallback, market-scoped prices) — if your storefront sends it, add country to the CDN cache key yourself. A hand-rolled cache (Next.js fetch cache, Redis) must key on locale, currency, channel and country.
Data translations — Mobility
Translatable models declare TRANSLATABLE_FIELDS and include Spree::TranslatableResource:
| Model | Fields |
|---|---|
Spree::Product |
name, description, slug, meta_title, meta_description |
Spree::Category |
name, pretty_name, description, permalink |
Spree::Collection |
name, description, permalink |
Spree::ProductType |
name |
Spree::OptionType, Spree::OptionValue |
label |
Spree::Store |
name, seo_title, meta_description, meta_keywords, support contact fields |
Spree::Policy |
name, body |
Spree::Seller |
name, about |
The registry is Spree.translatable_resources (drives the Admin API translation endpoints and dashboard translation editors). RICH_TEXT_TRANSLATABLE_FIELDS marks HTML fields so the dashboard shows a rich-text editor.
Custom field values (Spree::CustomField) are not Mobility-translated — one value per record.
How reads resolve
Every model uses translates(*TRANSLATABLE_FIELDS, column_fallback: Spree.mobility_column_fallback):
- The base column on the model table holds content in the store's default locale (
Spree::Current.content_locale). Reading/writing in that locale hits the base column. - Any other locale reads the translation table; missing values fall back (via the store fallbacks above) to the default locale → base column. A half-translated catalog still renders.
Spree::Config[:always_use_translations] = true(envSPREE_ALWAYS_USE_TRANSLATIONS) disables column fallback: all locales, including the default, go through translation tables. Leave itfalseunless you have a specific reason.
Mobility.with_locale(:es) { product.name } # translated or fallback
product.name(locale: :es, fallback: false) # nil if no Spanish row — detect gaps
Outside a request (console, jobs, rake) there is no store fallback configured and content_locale is unset. Set context first:
Spree::Current.store = store
Spree::Current.content_locale = store.default_locale
Spree::Locales::SetFallbackLocaleForStore.new.call(store: store)
Writing translations in Ruby
product.upsert_translations(
'es' => { 'name' => 'Camiseta', 'description' => '<p>Una camiseta</p>' },
'fr' => { 'name' => 'T-shirt' }
)
upsert_translations semantics: absent locale/field → untouched; "" → empty string (read falls back); nil → deletes that cell. Locales not in the record's store supported_locales_list raise ActiveRecord::RecordInvalid. Or write directly: Mobility.with_locale(:es) { product.update!(name: 'Camiseta') }.
Translated slugs
Product slug and Category/Collection permalink are per-locale — /produits/sac-spree in French. Slugs are unique within a locale, may repeat across locales. The Store API looks up the slug in the request locale and falls back to the default-locale slug. Old slugs are not auto-resolved (404) — redirect in the storefront.
Translations through the Admin API / dashboard
Merchants translate in the dashboard (per-record locale switcher, plus a centralized Translations page with coverage). The same surface is available to integrations (TMS sync, AI translation jobs):
| Endpoint | SDK (@spree/admin-sdk) |
Purpose |
|---|---|---|
GET /api/v3/admin/translatable_resources |
client.translatableResources.list() |
Registry: [{ resource_type, fields: [{ key, type }] }] |
GET /api/v3/admin/locales |
— | Store's supported locales (code, name, default, rtl) |
GET /api/v3/admin/products/:id/translations |
client.products.translations.get(id) |
Matrix: source values + per-locale translations (+ nested children, e.g. option values) |
GET /api/v3/admin/<resource>/:id?expand=translations |
— | Translation matrix inline on the record |
POST /api/v3/admin/translations/batch |
client.translations.batch(entries) |
The write surface — atomic across records/types |
GET /api/v3/admin/translations?resource_type=product |
client.translations.coverage('product') |
Per-locale coverage grid |
await admin.translations.batch([
{ resource_type: 'product', resource_id: 'prod_86Rf07xd4z',
values: { fr: { name: 'Sac Spree' }, de: { name: 'Spree Tasche' } } },
{ resource_type: 'option_value', resource_id: 'optval_…', values: { de: { label: 'Klein' } } },
])
resource_type tokens are underscored model names (product, option_type, option_value, category, collection, product_type, store, policy, seller). All entries succeed or none do. Secret keys need write_<resource> for every resource type in the batch. Translations are not written through PATCH /products/:id.
Bulk CSV: Spree::Exports::ProductTranslations / Spree::Imports::ProductTranslations (dashboard import/export; see spree-reporting).
Making your own model translatable
-
Model:
module Spree class Brand < Spree.base_class include Spree::TranslatableResource TRANSLATABLE_FIELDS = %i[name description].freeze RICH_TEXT_TRANSLATABLE_FIELDS = %i[description].freeze translates(*TRANSLATABLE_FIELDS, column_fallback: Spree.mobility_column_fallback) end end -
Migration for
spree_brand_translations(spree_brand_id,locale, the fields, timestamps; unique index on[spree_brand_id, locale]). Keep the base columns — they hold default-locale content. -
Register it (initializer, after core config loads):
Rails.application.config.after_initialize do Spree.translatable_resources += [Spree::Brand] endThat makes it writable through
POST /translations/batch(resource_type: 'brand'). For?expand=translations, includeSpree::Api::V3::Admin::Translatablein the Brand admin serializer. The nestedGET …/:id/translationsread endpoint needs its own route (core mounts it with a:translatableroute concern on core resources).
Adding a field to a core model: add the column to its translation table (and base table), then in a decorator redefine the frozen constant and call translates for the new field (never TRANSLATABLE_FIELDS << :x — FrozenError):
module Spree::ProductDecorator
def self.prepended(base)
fields = base::TRANSLATABLE_FIELDS + [:subtitle]
base.send(:remove_const, :TRANSLATABLE_FIELDS)
base.const_set(:TRANSLATABLE_FIELDS, fields.freeze)
base.translates :subtitle, column_fallback: Spree.mobility_column_fallback
end
Spree::Product.prepend self
end
Expose it in serializers and permitted attributes as usual.
Backend strings — Spree.t and YAML
Spree.t(:save) # spree.save
Spree.t(:paid, scope: 'payment_states')
Spree.t('custom_feature.title', default: 'Rewards')
- Add community translations:
spree bundle add spree_i18n(40+ locales, picked up automatically). - Override or add keys in your app's
config/locales/<locale>.ymlunder<locale>.spree.*— the app's files load last. - Enable Rails fallbacks with
config.i18n.fallbacks = [:en]inconfig/application.rb(settingI18n.fallbacksin an initializer doesn't enable the backend module). Production.rb usually hasconfig.i18n.fallbacks = true. Spree::BaseMailerconfigures the store's Mobility fallbacks, so translated data in emails resolves like in the API.- Keep YAML tidy with
i18n-tasks(normalize,missing,unused). - Permission catalog labels for scopes you register live under
spree.permissions_catalog.resources.<name>.
Dashboard strings — i18next
The React dashboard does not use Rails YAML. Strings live in i18next's translation namespace under an admin. prefix. Plugins ship bundles:
import en from './locales/en.json'
import de from './locales/de.json'
export default defineDashboardPlugin({
locales: { en, de }, // registered before the rest of the config
nav: [{ key: 'brands', label: i18n.t('admin.brands.title'), path: '/brands' }],
})
Conventions: admin.fields.<resource>.<attribute>.label; name types a backend gem registers (promotion rules, calculators, integrations…) under admin.types.<family>.<code> so the dashboard doesn't fall back to the Ruby label in the store's locale. See spree-dashboard-plugins.
RTL
Spree::Locale::RTL_LANGUAGE_CODES = ar he fa ur yi; Spree::Locale.new(code:).rtl? / .direction. GET /api/v3/admin/locales returns rtl. The storefront owns its dir="rtl" (set it in the Next.js layout from the active locale). The dashboard ships an Arabic bundle.
Common problems
- "translation missing: es.spree.…" — add the key in your app's
config/locales/es.yml, installspree_i18n, or enableconfig.i18n.fallbacks. - Spanish request still returns English — is
esin a market'ssupported_locales? (Unsupported header values are ignored.) Does the translation row exist (product.name(locale: :es, fallback: false))? Is a cache keyed without locale? - Locale missing from the storefront switcher —
store.supported_locales_listaggregates markets' locales; add it:market.update!(supported_locales: %w[en es fr]).I18n.available_localesalone doesn't expose it. - Batch write 422 "Unsupported locale(s)" — the locale isn't in the record's store supported locales.
- Console shows default-locale value for every locale — no store fallbacks / content locale set; see "How reads resolve".
- Stale value after writing in the same process —
product.reload.
Where to read further
node_modules/@spree/docs/dist/developer/core-concepts/translations.mdnode_modules/@spree/docs/dist/developer/core-concepts/slugs.mdnode_modules/@spree/docs/dist/developer/core-concepts/markets.mdnode_modules/@spree/docs/dist/developer/dashboard/customization/translations.md- Mobility: https://github.com/shioyama/mobility · spree_i18n: https://github.com/spree-contrib/spree_i18n