spree-i18n
Use when translating a Spree 6 store — adding a locale, translating product/category/collection content, fixing missing translations, building a multilingual storefront, syncing translations from a TMS via the Admin API, making a custom model translatable, working with Mobility, or wrangling `Spree.t` / dashboard i18next keys. Common phrasings include "add Spanish to Spree", "translate products", "Mobility", "translation tables", "RTL", "missing translation", "Spree.t", "fallback locale", "translated slugs", "translations API", "translate dashboard plugin", "X-Spree-Locale".
How do I install this agent skill?
npx skills add https://github.com/spree/agent-skills --skill spree-i18nIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill is a legitimate documentation resource for managing translations in Spree Commerce. It provides instructions for handling UI strings through Rails I18n and product data translations using the Mobility gem. No malicious patterns, obfuscation, or data exfiltration attempts were detected.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Spree I18n + Translations
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
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/spree/agent-skills/spree-i18n">View spree-i18n on skillZs</a>