Skip to main content

Theme Translations & Internationalization

SitePack includes native support for multi-language storefront translations. By utilizing localized translation dictionaries in JSON format, you can easily adapt your theme's headings, form labels, interface actions, and checkout messages for any country, region, or custom language profile.

This guide explains the translation folder structure, strict naming conventions, dynamic fallback hierarchy, translating keys inside templates, and rendering interpolation parameters.


The translations/ Folder

All translation file mappings must reside within the /translations folder at the root directory of your theme:

your-theme-directory/
├── translations/
│ ├── en.json # Base English translation
│ ├── nl.json # Base Dutch translation
│ ├── nl_be.json # Regional Dutch (Belgium) overrides
│ └── fr_ca.json # Regional French (Canada) overrides

Strict File-Name Conventions

SitePack is deployed across highly fast, case-sensitive Linux servers. To guarantee consistency and prevent template compilation lookup errors:

  1. Strict Lowercase Rule: All translation filenames MUST be formatted in strict lowercase.
    • Correct: nl_be.json, en_us.json
    • Incorrect: nl_BE.json, en_US.json, NL_BE.json
  2. Valid File Extension: Every translation file must end in the .json extension.
  3. Supported Formats: SitePack supports both two-letter primary language-level codes (en.json, nl.json, fr.json) and specific five-character regional/country codes containing an underscore divider (nl_be.json, en_gb.json, de_at.json).

Smart Translation Resolution & Fallbacks

SitePack implements a hierarchical resolution fallback algorithm. This is designed to maximize developer efficiency: you do not have to write hundreds of repetitive keys across multiple files. Instead, you only define base translations once and override specific localized regional terms in sub-files.

When a customer visits a store using a regional locale like nl_be (Dutch - Belgium), the SitePack compilation engine resolves keys in this strict order:

Customer Locale: "nl_be"


┌──────────────────────────────────────────────────┐
│ Step 1: Check Specific Regional File │ ──> Is the key in "nl_be.json"?
└──────────────────────────────────────────────────┘
│ (If not found or file missing)

┌──────────────────────────────────────────────────┐
│ Step 2: Check Base Language File │ ──> Is the key in "nl.json"?
└──────────────────────────────────────────────────┘
│ (If still not found)

┌──────────────────────────────────────────────────┐
│ Step 3: Render Raw Key String │ ──> Output the literal path (e.g. "cart.checkout")
└──────────────────────────────────────────────────┘

Fallback Practical Example

Consider the following two dictionary files configured inside your theme workspace:

translations/nl.json (Base Language)

{
"cart": {
"title": "Mijn Winkelwagen",
"checkout": "Afrekenen",
"empty": "Uw winkelwagen is momenteel leeg."
}
}

translations/nl_be.json (Regional Overrides)

{
"cart": {
"checkout": "Bestellen"
}
}

Here is how SitePack resolves and outputs translation strings for a customer whose session locale is nl_be:

  • {{ 'cart.title' | trans }}
    • Resolution: Not found in nl_be.json. Checks base file nl.json.
    • Result: "Mijn Winkelwagen"
  • {{ 'cart.checkout' | trans }}
    • Resolution: Match found in nl_be.json.
    • Result: "Bestellen" (custom Belgian terminology override).
  • {{ 'cart.empty' | trans }}
    • Resolution: Not found in nl_be.json. Checks base file nl.json.
    • Result: "Uw winkelwagen is momenteel leeg."

Formatting Dictionary Files (Nested Objects)

Translation keys are stored in standard JSON format. To keep your localization clean and readable, group related keys into nested objects.

{
"storefront": {
"welcome": "Welcome back!",
"contact_us": "Get in Touch"
},
"search": {
"placeholder": "Search catalog...",
"no_results": "No items matched your query."
}
}

Translating Keys inside Twig Templates

To render a translated string inside your storefront templates, use the built-in Twig filter trans applied to your key path.

1. Simple Key Lookup (Dot-Notation)

Use standard dot-notation to navigate nested objects inside your JSON translation dictionary:

{# Maps to the "placeholder" key inside the "search" object #}
<input type="text" placeholder="{{ 'search.placeholder' | trans }}">

{# Output: <input type="text" placeholder="Search catalog..."> #}

2. Parameter Interpolation Variables

To output dynamic, data-driven content (such as customer names, dates, or order statuses) within a static sentence, define interpolation placeholders wrapped in percent signs (%placeholder%) inside your JSON dictionaries. This follows the same convention used by the Symfony Translator that powers SitePack under the hood.

Then, pass the variables as a hashed array argument directly to the trans filter in your Twig file. The array keys must match the %placeholder% string exactly, percent signs included.

translations/en.json

{
"account": {
"welcome_customer": "Welcome, %name%",
"order_item": "Order #%id% - %date% - Status: %status%"
}
}

templates/account.twig

{# Safely rendering parameters inside translations #}
<h2>{{ 'account.welcome_customer' | trans({'%name%': customer.first_name ~ ' ' ~ customer.last_name}) }}</h2>

<p>{{ 'account.order_item' | trans({'%id%': order.id, '%date%': order.created_at | date('M d, Y'), '%status%': order.status}) }}</p>

{# Output: <h2>Welcome, Alex Johnson</h2> #}
{# Output: <p>Order #1042 - Jun 12, 2026 - Status: Shipped</p> #}
Common Mistake

Do not use Twig's own {{ variable }} interpolation syntax inside translation strings — SitePack translations are resolved server-side by the Symfony Translator, not by the Twig template engine, so only the %placeholder% format is recognized. A JSON string like "Hello, {{ name }}" will be rendered literally, curly braces and all.


Two Layers of Translation

It helps to keep two distinct concepts apart — they are configured differently and solve different problems:

LayerWhat it translatesWhere it livesHow you use it
UI stringsYour theme's own labels, buttons and messages ("Add to cart", "Search…").Your theme's /translations/*.json files (documented above).The trans filter: {{ 'cart.checkout' | trans }}.
Content & routesThe merchant's actual pages — products, categories, blog articles — and their per-language URLs.The merchant's purchased translation locales, served under a URL prefix (/nl/, /de/, /en/).The switcher helpers below.

The merchant enables extra languages (each with a URL prefix such as nl, de, en, cz) from their store admin. SitePack then serves every page at both the master URL and a prefixed URL per language, and resolves the equivalent URL of the current page in each language for you — this section is about surfacing those in your theme.


Translating Custom Template Pages

A page built on one of your custom templates has no page-builder content: it renders straight from the field values the merchant filled in, which your Twig reads as page['hero-title']. Those values are content, so they belong to the second layer above — the merchant translates them per language from Content → Translations in their admin, and SitePack renders the translated value into the very same page[...] lookup. Your template needs no change at all.

What your theme.json decides is which fields land in that editor:

In the translation editorNot in the translation editor
text, textarea and list fields holding real copy (list items appear one by one).image fields, and values that are not language: URLs and paths, prices and bare numbers, icon classes, true/false flags.

Every offered string is shown under the label you gave the field, so descriptive labels are what make the editor readable:

{ "key": "hero-title", "type": "text", "label": "Hero title" }

Override the automatic choice per field with "translatable": true or "translatable": false — see Which Fields Get Translated.

Keep links out of the copy

Give a link its own field (hero-cta-url) next to its label field (hero-cta-label). The label gets translated, the URL does not, and sitepack_slug() keeps an internal link on the language being read.


Rendering a Language Switcher

SitePack exposes the current page's language alternates to your templates through two Twig helpers. Both point at the equivalent page in each language (so a shopper viewing a product stays on that product when they switch), both carry the correct hreflang locale, and both render nothing when the store has only one language — so you can call them unconditionally.

Option 1 — Ready-made markup: sitepack_translations()

The fastest path. It renders a complete, accessible <nav> list of links with hreflang/lang attributes and an active class on the current language:

{# Drop it straight into your header snippet #}
{{ sitepack_translations() }}

{# …or override the wrapper class #}
{{ sitepack_translations({ class: 'lang-switcher' }) }}

Produces:

<nav class="sitepack-translations" aria-label="Languages">
<ul>
<li class="active"><a href="https://store.example/nl/test" hreflang="nl_NL" lang="nl_NL">NL</a></li>
<li><a href="https://store.example/de/test" hreflang="de_DE" lang="de_DE">DE</a></li>
</ul>
</nav>

Option 2 — Full control: sitepack_translations_list()

When you want your own markup (a <select>, a flag dropdown, buttons…), call sitepack_translations_list(). It returns a plain associative array keyed by the URL locale prefix (nl, de, en, …), mapping to the absolute URL of the current page in that language:

{% set languages = sitepack_translations_list() %}
{# → { nl: 'https://store.example/nl/test', de: 'https://store.example/de/test' } #}

{% if languages|length > 1 %}
<div class="lang-select">
<label for="lang">Language</label>
<select id="lang" onchange="location.href = this.value">
{% for prefix, url in languages %}
<option value="{{ url }}" {{ prefix == site.locale[:2] ? 'selected' : '' }}>
{{ prefix|upper }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
Comparing against the active language

site.locale holds the full active locale (e.g. nl_NL). The keys returned by sitepack_translations_list() are the two-letter URL prefix (e.g. nl), so compare with site.locale[:2] (the first two characters) as shown above.


Linking to Other Pages: sitepack_slug()

The switcher above is about this page in another language. The other half of the problem is every hard-coded link in your theme — a header nav item, a footer CTA, the "back to home" button on your 404 — because those are written once, in one language:

{# ❌ Throws a visitor reading /de/ out of their language and onto the main one #}
<a href="/pricing">{{ 'nav.pricing'|trans }}</a>

{# ✅ Resolves to /pricing, /de/preise, /fr/pricing — whatever is being served #}
<a href="{{ sitepack_slug('pricing') }}">{{ 'nav.pricing'|trans }}</a>

You address the page by its default slug — the one the merchant sees in the admin — and SitePack resolves it against the language the page is being rendered in, including the URL prefix. Nested pages take their full path (sitepack_slug('services/hosting')) and every segment is localized; sitepack_slug('/') gives you the homepage of the language being read.

Always safe to call

A page the merchant has not translated, a path that is not a page at all, or a store selling no translations: all fall back to the language prefix plus the path you passed, which is what the router serves anyway. So there is no single-language edge case to guard against — use it for every internal link.

Full signature in the Twig Functions Reference.


Linking to Products, Categories and Blog Posts

Those three are not addressed by a path of their own: they are served from a fixed route plus their own slug, so they have their own helpers — and you pass them the item, not one of its fields:

{# ❌ The address the listing was assembled with — the store's main language #}
<a href="{{ product.url }}">{{ product.name }}</a>

{# ❌ Worse: a 404 the moment the merchant translates that product's slug #}
<a href="/de/products/{{ product.slug }}">{{ product.name }}</a>

{# ✅ /products/red-shoe, /de/products/roter-schuh — whatever is being served #}
<a href="{{ sitepack_product_slug(product) }}">{{ product.name }}</a>

There is one per route:

HelperResolves to
sitepack_product_slug(product)/de/products/roter-schuh
sitepack_category_slug(category)/de/categories/schuhe
sitepack_blog_slug(article)/de/blog/mein-beitrag

Why the item and not the slug

A listing endpoint — the products of a category, the tiles beside them, a page of search results — assembles its items in the store's main language. That is what product.url is, and it is the same value for every visitor.

The slug a merchant gave that product in German lives in the store's translation data, and there can be thousands of them, so it is not something the store hands your theme up front. Instead each item states its own addresses: one per language, translated slug included. That is what these helpers read.

So the manual version fails in both directions. product.url on its own drops a German visitor back onto the main language. "/de/products/" ~ product.slug keeps the language but names an address whose canonical is somewhere else — and the mirror of it, /products/roter-schuh with no prefix, is a plain 404. Only the item knows which address is right.

Passing a hash into a snippet

If your listing builds a hash for a card snippet instead of passing the item, carry translations across with it — otherwise the card is back to the master language:

{% include 'snippets/category-card.twig' with { category: {
url: sub_category.slug,
translations: sub_category.translations|default([]),
categoryName: sub_category.name
} } %}
Also safe to call

An item with no translations at all — every item on a single-language store — keeps its own path, and a bare slug passed by hand keeps that slug behind the right language prefix. Both are addresses the router serves, so there is no edge case to guard against. Use them for every product, category and blog link, listings included.

Full signatures in the Twig Functions Reference.


Legal policy pages (Terms & Conditions, Privacy Policy, Impressum, …) are served from a fixed route (/legal/privacy-policy) whose slug is the same in every language; only the body is translated. Merchants translate that body from the admin's Content → Translations screen (content type "Legal pages"), and a visitor on /nl/legal/privacy-policy then reads the Dutch version.

If you build the footer yourself, link to a policy with sitepack_legal_slug() rather than a hardcoded /legal/…, so a translated visitor stays on their language:

{# ✅ /legal/privacy-policy, /de/legal/privacy-policy, whatever is being served #}
<a href="{{ sitepack_legal_slug('privacy-policy') }}">{{ 'footer.privacy'|trans }}</a>

The built-in sitepack_legal_links() and sitepack_content_legal() already do this for you, so if you use those you get locale-correct links for free.


hreflang alternates for SEO

The same set of alternates also powers the hreflang link tags search engines use to serve the right language version. Those <link rel="alternate" hreflang="…"> tags are emitted for you by sitepack_head() inside <head> — you do not need to build them yourself. The switcher helpers above are purely for the visible, on-page UI.


Exploring Further:

Now that you have configured multi-lingual translations, explore the built-in vector assets available for your UI designs in the Theme Icons Reference Guide!