Data

Multi-language

What Liquid already translates, and the strings React needs.

Most of multi-language already works, because Liquid does it. The part that doesn't is the words the theme itself writes.

What Liquid already handles

Content is translated before React sees it. Product titles, descriptions, collection names, blog posts — Shopify renders them in the active locale (Translate & Adapt, Markets), and the json--* serializers pass them through untouched.

1// Already French on a French storefront. Nothing to do.
2<h1>{product.title}</h1>

Prices and currency too. *_formatted fields come from Liquid's money filters, which know the active market's currency and format. That is exactly why you never re-format cents in JavaScript.

The switcher. useLocalization() gives the active country and language plus the available lists — see useLocalization.

What needed solving

Theme strings inside JSX. {% raw %}{{ 'x' | t }}{% endraw %} cannot reach a React component, so text like "Add to cart" and "Previous slide" was hard-coded English on every storefront.

1import { useTranslate } from '@/framework'
2
3function BuyButton({ adding }) {
4 const t = useTranslate()
5 return <button>{adding ? t('products.adding') : t('products.add_to_cart')}</button>
6}

Outside React, translate and getTranslate are the same function:

1import { translate } from '@/framework'
2
3const label = translate('a11y.previous')

How it works

Liquid resolves every string once per page into the theme context, and React reads it from there. No request, no async, no flash of English.

1{% comment %} snippets/theme-strings.liquid {% endcomment %}
2{
3 "products.add_to_cart": {% raw %}{{ 'products.product.add_to_cart' | t | json }}{% endraw %},
4 "a11y.previous": {% raw %}{{ 'accessibility.previous' | t | json }}{% endraw %}
5}

Two decisions worth knowing:

Resolved with | t, not read from the JSON file. The filter is what applies a merchant's Translate & Adapt edits — the locales/*.json in the repo is only the English source. Emitting the file directly would ship English to every storefront however well translated the shop is.

Keys are listed by hand. Liquid cannot iterate a locale file, so there is no "emit everything". That turns out to be right: only strings React actually renders get shipped, and an unused key cannot quietly bloat every page.

Adding a string

Three steps, and the build checks two of them.

1. Add it to src/liquid/locales/en.default.json:

1{
2 "products": {
3 "product": {
4 "notify_me": "Notify me when available"
5 }
6 }
7}

2. Emit it in snippets/theme-strings.liquid:

"products.notify_me": {% raw %}{{ 'products.product.notify_me' | t | json }}{% endraw %},

3. Use it:

1const t = useTranslate()
2<button>{t('products.notify_me')}</button>

npm run shopify:check fails if step 2 names a key that step 1 does not define, so the two cannot drift.

Interpolation

Placeholders are {name}, substituted client-side — because the values often are not known until React has the data.

1const t = useTranslate()
2
3t('products.availability_summary_html', { count: 3, total: 8 })
4// "Available at 3 of 8 locations"

The locale string uses Shopify's own syntax, and the snippet passes a literal placeholder through:

"availability_summary_html": "Available at {% raw %}{{ count }}{% endraw %} of {% raw %}{{ total }}{% endraw %} locations"
{% raw %}{{ 'products.product.availability_summary_html' | t: count: '{count}', total: '{total}' | json }}{% endraw %}

A placeholder you do not supply is left alone rather than rendered as undefined, so a half-wired string is visible instead of silently wrong.

Missing keys

t('nope.missing') returns the key itself, and warns in the theme editor. That is deliberate: a missing string should be obvious in review rather than rendering blank.

1t('nope.missing') // → 'nope.missing'
2t('nope.missing', null, 'Add to cart') // → 'Add to cart'

The limitation: pluralization

Interpolation is client-side; true pluralization is not solved here.

Shopify resolves plural forms server-side from a count that React often does not know at render time — a cart line count changes after Liquid has finished. Emitting one form and substituting a number gives "1 items" in English, and worse in languages with more than two forms.

None of the strings the theme currently ships need it. If you add one that does, the honest options are to emit each form as its own key and choose in the component, or to keep that particular string in Liquid.

What is not here

Currency conversion, market-specific pricing, and country-specific content are Shopify's job, not the theme's. useLocalization() reads the active market; Liquid renders the right prices. There is nothing for React to convert.