Translate product information
On this page
Theme locales translate the strings in your theme. They do not reach product information: Name, Display Name (s_c__Display_Name__c) and Summary (s_c__Summary_Markdown__c) live on the Product2 record, which is shared by every store in the org. A store serving another language shows translated buttons and navigation around untranslated product names.
This process keeps one product record per SKU and stores the translated copy alongside it as JSON, so price, inventory and reporting stay single. Adding another language adds a key to the JSON rather than a second catalog.
Before you begin
- A store whose Locale is set, and a theme locale for that language. See Language localization.
- Permission to add a custom field to
Product2and to create a custom data mapping.
Add a field to hold the translations
- On
Product2, create a Long Text Area custom field. This example usesTranslations__cwith a length of 32,768. - Give the field read access on the permission set your StoreConnect sync user holds, and on the profile of anyone who will edit it. A field with no access is not merely blank in Liquid: SOQL reports
No such column, which reads like the field was never created. - Add a custom data mapping for the object and field, with access level Read.
Adding a mapping starts a backfill, so allow time before the value appears on the storefront.
Store the copy as JSON keyed by locale
Populate the field with one key per locale code, matching the Locale value on the store:
```json
{ “es_MX”: { “display_name”: “Chamarra impermeable de senderismo”, “summary”: “Chamarra impermeable y resistente, ideal para caminar en condiciones climáticas severas.” } } ```
Use the store’s full Locale value as the key. A store set to es_MX does not match a JSON key of es, and the mismatch is silent: the lookup returns nothing, and the page renders the untranslated fallback with no error to explain why.
This shape is a convention this process introduces, not a platform contract. Pick your own key names if they suit your catalog better, as long as the template reads the same ones.
Load these with Data Loader or a script rather than by hand. A machine translation service is a reasonable way to generate the values, run once against the catalog. Translating on each page render adds latency and cost to every view, and leaves the page dependent on that service being up.
Read the JSON in your template
Parse the field with deserialize, select the current store’s locale, and fall back to the untranslated field:
```liquid
{%- assign translations = current_product.data[‘Translations__c’] | default: ‘’ | unescape | deserialize -%} {%- assign locale_copy = translations[current_store.locale] -%} {%- assign product_name = current_product.name -%} {%- if locale_copy.display_name != blank -%} {%- assign product_name = locale_copy.display_name -%} {%- endif -%}
{{ product_name }}
```
The field name is matched case-insensitively, so Translations__c and translations__c both resolve.
Unescape before you deserialize
A mapped Long Text Area reaches Liquid HTML-escaped: the quotes in the stored JSON arrive as ". deserialize cannot parse that, and it does not raise. It returns an empty value, every lookup off it is blank, and the template renders the fallback with no error to explain why.
| unescape before | deserialize is what makes the parse succeed. If a deserialize call returns nothing, test the filter against a literal on the same page to tell a parsing problem apart from a data problem:
```liquid
{%- assign check = ‘{“name”:”Dog & Cat”}’ | deserialize -%} {{ check.name }} ```
A literal that parses while the field does not means the value is escaped.
Guard the field before you unescape
| default: '' before | unescape is not optional. A product with no value in the mapped field is the ordinary case while a catalog is part-translated, and passing that empty value into unescape causes the {% if %} tests that follow to be skipped. The assignments inside them never run, the variable keeps whatever it held before, and nothing is raised.
Where the product genuinely has no translation, the page comes out right by accident. Where a later branch would have supplied one, it does not. This is what breaks the variant fallback below, which reads the variant’s own field first and only then looks to the master: the variant’s blank field skips the test that would have reached the master’s translation, so the page renders the untranslated summary and looks finished.
Keep the guard on every chain that reads a mapped field. It changes nothing on a product that has a value.
Translate the summary through a different accessor
The summary needs its own handling, because the drop has no summary attribute. It exposes summary_content, which reads s_c__Summary_Markdown__c and returns rendered HTML. A value taken from your JSON is plain text, so pass it through markdown to match:
```liquid
{%- assign product_summary = current_product.summary_content -%} {%- if locale_copy.summary != blank -%} {%- assign product_summary = locale_copy.summary | markdown -%} {%- endif -%}
```
Apply markdown to the JSON value only. Running it over summary_content, which is already rendered, mangles the output.
The markdown filter needs a recent platform version. On an older store it is not registered, and an unregistered filter raises rather than rendering blank, so the page fails outright instead of quietly falling back. Update your store to the current release before using this pattern.
Match the platform’s inheritance on variants
If your catalog uses master and variant products, the platform and your custom field inherit differently, and the gap between them puts untranslated text on a translated page.
| Value | On a variant whose own value is blank |
|---|---|
name |
Reads the variant’s own Display Name, falling back to the variant’s own Name. Never the master’s. |
summary_content, and downloads_content, features_content, specifications_content, support_content, warranty_content |
Falls back to the master product’s value. |
data['Translations__c'], and any other custom field |
Stays blank. Custom data is read from that record only. |
The name is consistent: the platform reads the variant’s own value and so does your translation, so a missing translation shows the variant’s own name rather than the master’s. The summary is not. The platform inherits the master’s summary while your translation does not inherit the master’s JSON, so a variant with no summary of its own and no translation of its own renders the master’s untranslated summary. The page looks complete, in the wrong language, with nothing to indicate a translation was missing.
Mirror the platform field by field. Read the name from the variant’s own JSON, and let the summary fall back to the master’s JSON in the same way summary_content falls back to the master’s summary:
```liquid
{%- assign summary_copy = locale_copy.summary -%} {%- if summary_copy == blank -%} {%- if current_product.variant? -%} {%- assign master_translations = current_product.master.data[‘Translations__c’] | default: ‘’ | unescape | deserialize -%} {%- assign master_copy = master_translations[current_store.locale] -%} {%- assign summary_copy = master_copy.summary -%} {%- endif -%} {%- endif -%}
{%- assign product_summary = current_product.summary_content -%} {%- if summary_copy != blank -%} {%- assign product_summary = summary_copy | markdown -%} {%- endif -%} ```
current_product.master is itself a product, so it exposes data the same way. Guard the lookup with variant?, because a master has no master of its own.
Giving every variant its own JSON also works and needs no fallback logic. It costs a translation for each variant of each product, which is worth it only where variants genuinely differ in description rather than in size or color.
Repeat the lookup in every snippet that shows a name
A product name renders in more places than the detail page, and the lookup does not travel to them on its own. {% render %} does not inherit the caller’s assigns, so a variable assigned in a page template is not visible inside the snippet it renders. The base theme renders listing cards this way, from snippets/products/cards:
```liquid
{%- cache “product”, items: [product, current_store, current_customer] -%} {%- render “products/card”, product: product, allow_comparisons: allow_comparisons %} {%- endcache -%} ```
Two things follow from that. The lookup has to live inside the snippet that outputs the name, not in the template that renders the snippet. And inside a card the product is the parameter product, not current_product: current_product is empty on any page that is not a product detail page, so pasting the block above into a card renders a blank title with no error text.
The cache block is not what hides the variable, and moving an assign inside it does not help. {% render %} is what isolates the scope.
To avoid maintaining the same six lines in several templates, put the lookup in one snippet of your own that takes the product as a parameter and outputs the translated name, then render that snippet from the product page and the card.
Prefer an explicit condition to a default
Test the translated value with {% if ... != blank %} rather than chaining | default: off it. The explicit form states the fallback, and it behaves the same whether the locale is absent, the key is absent, or the mapping has not finished backfilling.
This is a different use from the | default: '' above. That one guards the raw field on its way into unescape, before anything has been parsed. This one is about choosing what the reader sees once the lookup has run.
Verify
- Open a product page on the translated store and confirm the name and summary are translated.
- Open the same product on a store using the original language and confirm it is unchanged. Both read the same product record, so this is the check that the fallback works.
- Open a category page and confirm the listing cards are translated as well as the detail page.
- If your catalog uses variants, open a variant that has no summary of its own and confirm its summary is translated. An untranslated summary here means the master fallback is not being reached.
If a product renders in the original language, confirm the locale code in the JSON matches the store’s Locale exactly, then see Verify custom data is available in Liquid.
What this does not cover
Purchase button labels are not part of this. They come from the Add to Cart Text and Buy it Now Text fields, which resolve in order: the price book entry, then the price book (Pricebook2), then the sc.products.default_text.add_to_cart and sc.products.default_text.buy_it_now locale keys. Setting the text on the price book that serves the translated store covers the whole catalog at once. Leave both field levels blank to fall through to the locale keys, then translate those on the theme locale, and allow a few minutes and a cache refresh before checking.
Was this article helpful?
Thanks for your feedback! It helps us improve our docs.