Queek docs

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:

theme/locales/en.default.json (excerpt)
{
  "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.

BudgetLimitWhy
Key grammardotted lowercase [a-z0-9]+(\.[a-z0-9]+)*, no hyphens, capitals, spaces or empty segmentsHyphens belong to the theme slug, so <slug>.<key> stays unambiguous
Key (suffix)at most 40 charactersThe overlay stores <theme-slug>.<key> in one varchar(64): 40 for the key, 23 for the slug, 1 for the dot
Theme slugat most 23 characters, [a-z0-9-]Same varchar(64) budget
Valueat most 1000 characters, plain text, non-empty, no raw HTMLTranslation values cap at 1000 chars; escape brackets shoppers must see as &lt;
Fileat most 3400 keysThe per-file locale limit
Locale file nameen.default.json for English; other files <code>.json with a BCP-47-ish code of at most 12 charactersEnglish is the fallback authority every translation is checked against
Plural mapsone/other (full CLDR set allowed in locale files), other requiredother 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.

Client component (from themes/default/components/product-list.tsx)
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:

Interpolation (from themes/default/components/product-modal.tsx)
t('modal.add', { total: formatMoney(totalPrice, currency) })
Its English value in themes/default/locales/en.default.json
"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:

Plural map in themes/default/locales/en.default.json
"vendor.details.reviewcount": {
  "one": "({count} review)",
  "other": "({count} reviews)"
}
Its call site (from themes/default/components/vendor-header.tsx)
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):

theme/manifest.ts (from themes/default/manifest.ts)
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:

  1. Merchant override for the active locale (Planned: the reader returns nothing today, so this step is empty).
  2. Host locale dictionary: the active theme's locales/{lang}.json (falling back to the theme's en.default.json when the language file is absent).
  3. Platform pack: lib/storefront/i18n/packs/{lang}.json (then en.json) — host chrome and kit-core translations for the active locale.
  4. Manifest English: your locales/en.default.json via the strings import.
  5. 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.

Dry-run first: writes nothing, prints the diff and the report
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):

RuleWhat it checks
theme/locale-key-namingKeys 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-parityEvery 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:

Check your theme (locale rules report at warn level until the next theme-check release)
queek theme check

Full 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.ts labels 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.

On this page