Skip to content

Locales and translation

Every piece of visible text in a theme — button labels, empty-state messages, editor setting labels — should come from a translation key, not be hard-coded in a section. Keys live in the theme’s locales/ folder, and the engine resolves them to the shopper’s locale at render time. This page explains the file layout, the difference between storefront and schema strings, and the two ways you pull a string out: the t filter in Liquid and the t: prefix in schema JSON.

The locales/ folder

Translation files live in locales/ at the root of the theme (alongside templates/, sections/, and config/ — see Theme architecture). There are four kinds of file, split along two axes: who reads the string (storefront vs the editor’s schema), and whether the file is the default fallback for its language.

FileUsed forRole
{locale}.jsonStorefront strings (rendered by t)Translation for that locale
{locale}.default.jsonStorefront stringsThe default file for that language — also the source the editor reads when generating translation entries
{locale}.schema.jsonEditor/schema strings (t: labels, info, options)Translation for that locale
{locale}.default.schema.jsonEditor/schema stringsThe default schema file for that language

{locale} is a language tag such as en, en-IN, or hi. A minimal theme ships just two files — en.default.json and en.default.schema.json — and that is enough for a single-language store.

Storefront strings vs schema strings

The two groups never mix, and they are loaded separately:

  • Storefront strings (.json, .default.json) are everything a shopper sees — {{ 'products.product.add_to_cart' | t }}, cart copy, account-page labels. These are resolved by the t / translate Liquid filter while a page renders.
  • Schema strings (.schema.json, .default.schema.json) are everything the theme editor shows you — the labels, info help text, content, and option labels in a {% schema %}. These are referenced with the t: prefix (covered below) and resolved when the editor builds its settings panels.

Keeping them apart means a key like products.product.title can exist in your storefront file without bloating the editor bundle, and an editor label like t:settings.heading never leaks into rendered HTML.

Using the t (translate) filter

t and translate are the same filter — use whichever reads better. Pass a dotted key that matches the structure of your storefront JSON:

{{ 'products.product.add_to_cart' | t }}
{ "products": { "product": { "add_to_cart": "Add to cart" } } }

If the key is missing in every file in the fallback chain, the filter returns the key itself rather than throwing — so a typo shows up as literal products.product.add_to_cart on the page, which is easy to spot.

Interpolation

Pass named arguments and reference them with {{ name }} inside the string:

{{ 'customer.greeting' | t: name: customer.first_name }}
{ "customer": { "greeting": "Welcome back, {{ name }}" } }

By default the rendered string is HTML-escaped in full, so any markup in the translation shows as literal text. To allow HTML in a translation — for example a string that wraps part of itself in a link — end the key with _html. For a key ending in _html, the surrounding markup is rendered as HTML; the values you interpolate into it are still escaped, so a {{ name }} you pass in can’t inject tags:

{ "cart": { "terms_html": "I agree to the <a href=\"{{ url }}\">terms</a>" } }

Counts and pluralization

Pass count and the filter picks the right plural form. Provide the forms as an object whose entries can include zero, one, and other (plus any CLDR plural category the locale uses, selected via Intl.PluralRules):

{{ 'cart.items_count' | t: count: cart.item_count }}
{
"cart": {
"items_count": {
"zero": "Your cart is empty",
"one": "{{ count }} item",
"other": "{{ count }} items"
}
}
}

With cart.item_count of 3, this renders 3 items. The matching form is then interpolated, so {{ count }} is available inside it.

The t: convention in schemas

Inside a {% schema %}, you don’t call the t filter — you set a value to a string that starts with t: followed by a key in your schema locale files. The editor resolves it to the active locale before drawing the panel. It works for label, info, content, and the label of each select / radio option:

{
"name": "t:sections.featured.name",
"settings": [
{
"type": "text",
"id": "heading",
"label": "t:sections.featured.heading.label",
"info": "t:sections.featured.heading.info"
},
{
"type": "select",
"id": "layout",
"label": "t:sections.featured.layout.label",
"options": [
{ "value": "grid", "label": "t:sections.featured.layout.grid" },
{ "value": "list", "label": "t:sections.featured.layout.list" }
]
}
]
}
{
"sections": {
"featured": {
"name": "Featured products",
"heading": { "label": "Heading", "info": "Shown above the grid" },
"layout": { "label": "Layout", "grid": "Grid", "list": "List" }
}
}
}

A value that doesn’t start with t: is treated as a literal string and shown as-is, so you can mix translated and plain labels freely. If a t: key can’t be found, the editor falls back to showing the key text — another easy-to-spot signal that the entry is missing from your schema files.

The fallback chain

When you ask for a key in a given locale, the engine tries a sequence of files and returns the first match. For a request locale like en-IN, the storefront chain is, in order:

  1. locales/en-IN.json, then locales/en-IN.default.json

  2. locales/en.json, then locales/en.default.json (the base language, en-IN stripped to en)

  3. locales/en.default.json, then locales/en.json (an explicit final fall-through to English)

Schema strings follow the identical chain with the .schema.json / .default.schema.json variants. The practical upshot: en is always the ultimate fallback, so as long as your English default files are complete, no key ever renders as a raw key in production — even for a partially-translated locale.

How the active locale is chosen

For each request the engine resolves one active locale, in this order:

  1. An explicit locale passed to the renderer. This is how the editor and preview render in a chosen language (the editor preview endpoint also accepts a ?locale= query parameter); it is not used by the public storefront.

  2. A published locale prefix in the URL path — for example /hi/products/.... A published non-primary locale is served from its lowercase prefix; the primary locale is never duplicated behind a prefix.

  3. The store’s primary locale, used when nothing else is specified.

The resolved tag is what t and t: look up, and it’s also exposed to Liquid on the request and localization objects (see Liquid objects).

Switching the active language

Use the localization form rather than constructing a POST URL. The tag keeps the current locale prefix and adds CSRF protection:

{% form 'localization', id: 'LanguageForm', return_to: request.path %}
<select name="locale_code" onchange="this.form.submit()">
{% for language in localization.available_languages %}
<option
value="{{ language.iso_code }}"
{% if language.iso_code == localization.language.iso_code %}selected{% endif %}
>
{{ language.endonym_name | default: language.name | escape }}
</option>
{% endfor %}
</select>
<noscript><button type="submit">Change language</button></noscript>
{% endform %}

The generated action posts to /localization. locale_code must name a published locale (locale and language_code are accepted aliases). When the buyer changes language, Sellerlane replaces an existing published prefix, removes it for the primary locale, and preserves the remaining path, query, and fragment. return_to can be back or a same-store relative path; external and protocol-relative values are rejected.

Product, collection, page, blog, article, search, filter, pagination, and form URLs are generated with the active prefix. Canonical and hreflang links follow the same published-locale mapping. Language selection does not change country, catalog, currency, or Shopify Markets state.

Workflow: adding a string

  1. Add the key to locales/en.default.json (storefront) or locales/en.default.schema.json (editor), nested to match how you’ll reference it.

  2. Reference it{{ 'your.key' | t }} in a .liquid file, or "label": "t:your.key" in a {% schema %}.

  3. Save or import the theme. Schema strings are validated on save; a locales object that isn’t a JSON object is rejected. See Edit theme code and Import a theme ZIP.

  4. Verify by rendering the page (or opening the editor). A raw key on screen means the lookup missed — check the spelling and that the entry lives in the right file kind.

Troubleshooting

SymptomLikely cause
A literal key like products.product.add_to_cart rendersThe key is missing from the storefront files in the fallback chain, or it’s misspelled
A t: label shows the key text in the editorThe key is missing from the .schema.json files, or it was put in a storefront .json file by mistake
{{ name }} shows up literally in outputYou referenced an argument you didn’t pass — check the named args on the filter call
HTML in a translation renders as escaped textThe key doesn’t end in _html; rename it or move the markup into the template
A /xx/ URL 404sThe locale is not published (or the primary locale was incorrectly prefixed); only real published non-primary prefixes are routable

Next steps

Theme architecture

How locales/ fits with the rest of the theme structure.

Section schema

Section-scoped locales and t: labels in section schema.