Translating your theme
Locale files, the t function, key budgets, and what is (and is not) translatable today.
Every word your theme prints — headings, button labels, aria-labels, placeholders, empty states — must come from t(), never a hard-coded string. Merchant data (product titles, menu labels, vendor name) stays as props: only your theme's own chrome is translatable.
What ships today is English: the starter and the default theme carry English dictionaries, and any shopper language without a translation file reads the English value. The sections below describe exactly that mechanism. Anything not yet released is marked Not yet released or Planned — never assume it exists.
Locale files
File layout: theme/locales/en.default.json holds every key with its English value. Each extra language is theme/locales/{lang}.json with the same keys. In the starter, theme/locales/en.default.json is the template — copy it and change the values, never the keys — and locales/fr.json at the package root is a worked French sample to copy from (it says so in its own _example entry). The default theme ships English only: its locales/ holds en.default.json and no other language yet.
Both spellings resolve to the same key: nested scopes ({"cart": {"title": "…"}}) and flat dotted keys ({"cart.title": "…"}) are the same cart.title leaf. A real excerpt from the default theme's English file:
{
"cartpanel.close": "Close cart",
"cartpanel.count": {
"one": "{count} item ready for checkout",
"other": "{count} items ready for checkout"
},
"cartpanel.empty.title": "Your cart is empty",
"list.search.placeholder": "Search products…",
"modal.add": "Add to cart · {total}",
"footer.rights": "© {year} {name}. All rights reserved."
}A shopper whose language has no file, or a key missing from it, reads the English default value — as long as the key exists in en.default.json; a key used in JSX but missing from en.default.json renders empty (see Fallback chain). The raw dotted key is never rendered.
Key grammar and budgets
Before minting a key, check the kit's English defaults: if the kit already owns the string (anything under cart.*, …), call that key instead. A key is either a leaf value or a parent scope, never both: header.cart beside header.cart.count is flagged (warn-only today) — name the leaf header.cart.label.
| Budget | Limit | Why |
|---|---|---|
| Key grammar | dotted lowercase [a-z0-9]+(\.[a-z0-9]+)*, no hyphens, capitals, spaces or empty segments | Hyphens belong to the theme slug, so <slug>.<key> stays unambiguous |
| Key (suffix) | at most 40 characters | The overlay stores <theme-slug>.<key> in one varchar(64): 40 for the key, 23 for the slug, 1 for the dot |
| Theme slug | at most 23 characters, [a-z0-9-] | Same varchar(64) budget |
| Value | at most 1000 characters, plain text, non-empty, no raw HTML | Translation values cap at 1000 chars; escape brackets shoppers must see as < |
| File | at most 3400 keys | The per-file locale limit |
| Locale file name | en.default.json for English; other files <code>.json with a BCP-47-ish code of at most 12 characters | English is the fallback authority every translation is checked against |
| Plural maps | one/other (full CLDR set allowed in locale files), other required | other is the fallback form the kit renders when no form matches |
Calling t: client components
Client components (anything with 'use client'): the existing provider already carries the dictionaries, so just consume them — add no provider.
import { useThemeStrings } from '@usequeek/theme-kit/provider';
export function ProductList(): JSX.Element {
const t = useThemeStrings();
// ...
placeholder={t('list.search.placeholder')}
}aria-labels go through t too (from themes/default/header.tsx):
aria-label={t('header.cart.open')}Calling t: server components
Server components cannot call useThemeStrings: the host builds the bound t per request with getThemeStrings (see below) and server components receive it via props/closure from a server parent (or lift the copy up). Keep English as the default when no strings arrive, so the theme still renders without them. The type of that bound function is ThemeStringsFn from @usequeek/theme-kit/strings/theme-strings.
On the host side, the storefront builds the bound t per request for the active locale only (from lib/storefront/i18n/theme-strings.ts):
export async function getThemeStrings(themeSlug?: string | null): Promise<ThemeStringsFn> {
return (await getRequestThemeStrings(themeSlug)).t;
}getRequestThemeStrings is cached, so one request builds once, and the layout passes only that locale's dictionaries to StorefrontProvider. The codemod never rewrites server files: they need the bound t via props, which it does not guess.
Interpolation
Values use {var} placeholders, filled from the second argument:
t('modal.add', { total: formatMoney(totalPrice, currency) })"modal.add": "Add to cart · {total}"More real calls from the default theme:
{copyright ?? t('footer.rights', { year: new Date().getFullYear(), name: vendor.name })}{vendor.rating_value.toFixed(1)} {t('vendor.details.reviewcount', { count: vendor.rating_count ?? 0 })}A missing or nullish variable keeps its {placeholder} — visible and greppable, never a crash. {{ and }} escape to literal braces.
Plurals
Counts need plural maps, not ternaries: { "header.cart.count": { "one": "Cart ({count})", "other": "Cart ({count})" } } renders through Intl.PluralRules per shopper locale — t('header.cart.count', { count }). The default theme's real pair:
"vendor.details.reviewcount": {
"one": "({count} review)",
"other": "({count} reviews)"
}t('vendor.details.reviewcount', { count: vendor.rating_count ?? 0 })When no count is passed, or the locale selects a form the map does not carry, the kit renders other. An unknown or unpublished locale renders English, so plural selection never silently follows the wrong language.
The manifest strings import: REQUIRED
Reference your English file from your theme manifest, or your English is blank outside the vendor layout (preview hosts pass no dictionaries of their own — the kit falls back to what your manifest carries):
import type { ThemeManifest } from '@usequeek/theme-kit/types/theme';
import strings from './locales/en.default.json';
const manifest: ThemeManifest = {
/* … */
/**
* ENGLISH ONLY. The theme's own English travels with the theme: the kit
* layers this between host-loaded locale packs and kit core English, so
* preview hosts that pass no `strings` still render English, never empty
* labels. Per-locale packs (`fr.json`, …) stay host-loaded, never bundled.
*/
strings,
};The manifest carries ENGLISH ONLY — extra languages stay host-loaded per locale and are never bundled into the theme.
Fallback chain
First hit wins, in this order:
- Merchant override for the active locale (Planned: the reader returns nothing today, so this step is empty).
- Host locale dictionary: the active theme's
locales/{lang}.json(falling back to the theme'sen.default.jsonwhen the language file is absent). - Platform pack:
lib/storefront/i18n/packs/{lang}.json(thenen.json) — host chrome and kit-core translations for the active locale. - Manifest English: your
locales/en.default.jsonvia thestringsimport. - Kit core English: the kit's own default dictionary — never the raw dotted key, never "translation missing" text.
A key absent from every dictionary renders '', and logs the missing key once per key in development only. In production that state means a key the English file should have carried: a key absent from en.default.json is a theme bug, not a runtime state. A loader that throws or returns nothing (for example a missing locale file) simply drops out of the chain — never an error.
Merchant content vs theme text
Only your theme's own chrome is translatable. Products, pages and menus are translated by the content overlay, not theme strings — keep rendering them from props. Block body text is not yet translatable: copy a merchant types into a block stays merchant content and is out of scope for theme strings.
Per-locale loading
The server loader reads only the active locale's dictionaries (override, theme, platform pack) and passes only that locale to the client provider; the provider then layers manifest English and kit core English underneath. Never statically import every {lang}.json: ten locales of a few hundred keys each is hundreds of kilobytes per theme that every shopper would download. A bundle-guard test fails a client bundle that statically imports all locales.
Which languages exist
A store's languages are the locales published for it — the store locales catalogue served by GET /api/v1/client/store/locales (rows of locale, name, native_name, is_primary, path_prefix, hreflang, html_lang, dir; the primary first). Its source of truth is config/locales.php in the backend (default en; supported list includes fr, es, pt, de, ar, sw, ha, yo, ig, pcm, zh-CN, …). Published non-primary locales live under a path prefix; the primary is unprefixed. An unknown or unpublished locale renders English. The shopper's locale reaches the theme through the host — themes never read locale headers, never append locale= themselves, and never build a language switcher.
The codemod
scripts/i18n-codemod.ts in the storefront repo migrates a theme's hard-coded copy to t() calls. It parses with the TypeScript compiler API — no new dependency — and rewrites only high-confidence UI copy (pure JSX text, text mixed with simple expressions, aria-label/placeholder/title/alt/label attributes, ${expr} template literals in copy position, ||/?? fallback literals). It never rewrites routes, URLs, CSS tokens, currency codes, brand names, strings already inside t(), or merchant data — and it reports conditionals, plurals and object literals for human review instead of guessing them.
npx tsx scripts/i18n-codemod.ts <themeDir> [--dry-run] [--apply]--dry-run is the default. --apply writes the rewritten files, locales/en.default.json, and the strings import in manifest.ts. Review the dry-run diff first. First-party only today: the codemod runs from the storefront repo against its own themes.
theme-check rules
Not yet released — available in the next theme-check release, warn-only. Two rules check locale files, both defaulting to warn (warn one release, then reject for third-party submissions):
| Rule | What it checks |
|---|---|
theme/locale-key-naming | Keys are dotted lowercase (≤40 chars, no hyphens), values are strings or plural maps (≤1000 chars, no HTML, no empties), slug fits ≤23 chars, files parse as JSON objects |
theme/locale-file-parity | Every locales/{lang}.json key exists in en.default.json (extra keys never render); missing keys fall back to English; interpolation variables stay a subset of the English ones; string-vs-plural shapes match |
Planned: theme/no-hardcoded-strings (JSX text and aria/placeholder/title literals must go through t) and a missing-key build failure. Today a forgotten key renders empty in production — see Fallback chain.
Run the check the same way Queek runs it on submit:
queek theme checkFull command reference: CLI reference. Full rule list: checks reference.
What is not translatable yet
- Block body text (merchant content, content-overlay scope).
- Theme editor labels:
theme.config.tslabels and descriptions, block setting labels. These are merchant-admin text, not shopper text — merchant admin language is a separate problem. - Emails and other off-storefront copy.
- Non-English platform packs: the host ships the English pack only; other languages fall back to English until translated packs land.
- Merchant overrides of theme strings: there is no reader yet — the override step of the fallback chain is empty.
- Typed keys (
keyof en.default.json) and the pseudo-locale QA helper.
Roadmap
Planned, not built — no dates promised: the merchant-override reader, non-English platform packs (French first), the hard-coded-string lint plus missing-key build failure, typed keys with the pseudo-locale helper, the registry writer for English defaults, and locale-aware dates and numbers (English keeps its current British/Nigerian style, so English stores do not change).
Shopify comparison
The shape mirrors Shopify's theme locales (locales/en.default.json plus {lang}.json, a t lookup with interpolation and CLDR plurals, fallback to the default language) — see Shopify's locale docs, which theme-check also cites for its file limits. Shopify's full theme-check rule list is unverified, and what Shopify renders when a key is missing everywhere is unknown from its docs — Queek renders '' with a development-only warning instead.