Translations & i18n

Multi-language storefronts, and how Brainerce overlays locale-specific content on every entity the buyer sees.

Brainerce ships a single shared translation system used by every product-facing entity. The merchant edits translations in one place (the dashboard) and storefronts get translated content back on every read, without writing any locale-specific code.

The mental model

Every translatable entity carries a base value (the store's default language) plus a translations JSON column keyed by locale:

{
  "id": "prod_xyz",
  "name": "Cheese Pizza", // base (store default = "en")
  "description": "Classic margherita…",
  "translations": {
    "he": { "name": "פיצה גבינה", "description": "מרגריטה קלאסית…" },
    "ar": { "name": "بيتزا الجبن" },
  },
}

When the storefront sets a locale, the server overlays the locale's fields on top of the base entity and returns a flat shape, so your render code stays the same:

client.setLocale('he');
const product = await client.getProductBySlug('cheese-pizza');
product.name; // → "פיצה גבינה"
product.description; // → "מרגריטה קלאסית…"
// Untranslated fields fall back to the base value, never empty strings.

Locales not in the entity's translations map fall through to the store's default, so there are no missing-string crashes.

What's translatable

The following entities have a translations column and are overlay-resolved on every read:

EntityTranslatable fields
Productname, description, seoTitle, seoDescription, slug
ProductVariantname
Categoryname
Brandname
Tagname
Attributename (e.g. "Color")
AttributeOptionname (e.g. "Red", "Large")
ModifierGroupname, description
Modifiername, description
ProductMetafieldvalue (free-text metafield values)
MetafieldDefinitionname, description (custom-field labels)
BundleOffername, description (the bundle's own marketing label)
OrderBumpConfigtitle, description (the bump headline shown at checkout)
DiscountRulename, description (merchant-facing rule labels)
BlogPosttitle, excerpt, content, seoTitle, seoDescription, slug
ContactFormname, description, submitButton, successMessage
ContactFormFieldlabel, placeholder, helpText
StoremetaDescription

If an entity isn't in this list, only its base fields will be served.

The bundle/bump/discount-rule own labels translate via these new columns. The products inside a bundle (or the product targeted by a bump) translate via Product.translations. See the next section.

Setting the active locale on the storefront

SDK

import { BrainerceClient } from 'brainerce';

const client = new BrainerceClient({ salesChannelId: 'vc_…' });

// Once at app boot — adds Accept-Language to every subsequent request
client.setLocale('he');

// Or per-call override:
const product = await client.getProductBySlug(slug, { locale: 'he' });

Direct REST

fetch(`${BASE_URL}/products/${id}`, {
  headers: { 'Accept-Language': 'he' },
});

The server reads Accept-Language and applies the overlay for that locale. Multi-locale headers like he, en;q=0.8 resolve to the first locale the store supports. If the store has en, he configured and the buyer sends he, en;q=0.8, the server returns Hebrew.

A ?locale=he query parameter is honored as a fallback when you can't set the header (e.g. a plain link). Either way, the server only activates a locale that is (1) in the store's supported locales and (2) different from the store default. Requesting the default locale, or one the store doesn't support, simply returns the base fields. This gating is why an unconfigured locale silently "does nothing" rather than erroring.

Search in the active language

Product search respects the active locale. With setLocale() (or Accept-Language) set, a search query matches the product's translated name/description for that locale, not only the default-language fields. So a shopper browsing in Hebrew finds "פיצה גבינה" by typing Hebrew, even though the base name is English:

client.setLocale('he');
await client.getProducts({ search: 'פיצה' }); // matches translations.he.name

The base columns are always searched too, so results never shrink versus the default-language search; the translated match is additive. (Matching translated values is case-sensitive on non-Latin scripts; the base-column match stays case-insensitive.)

RTL detection

For Hebrew, Arabic, Persian, Urdu, and Yiddish the storefront should flip its layout direction. The SDK ships a helper:

import { BrainerceClient } from 'brainerce';

const client = new BrainerceClient({ salesChannelId: 'vc_…' });

await client.setLocale('he');
const dir = await client.getStoreDirection(); // → 'rtl'

// Or sync from a known BCP-47 tag without an SDK roundtrip:
import { getDirectionForLocale } from 'brainerce/utils';
getDirectionForLocale('he-IL'); // → 'rtl'
getDirectionForLocale('en-US'); // → 'ltr'

Apply it to <html dir={dir}> in your layout. Brainerce treats he, ar, fa, ur, yi as RTL, and everything else as LTR.

Listing supported locales

const locales = await client.getSupportedLocales();
// → { defaultLocale: 'en', supportedLocales: ['en', 'he', 'ar'] }

Use this to build a language switcher. Always include the default, because it's the fallback when a target locale has gaps.

SEO: per-locale slugs and hreflang

When the merchant translates a product's slug, every locale gets its own URL. Pull all alternates in one call:

const alternates = await client.getProductAlternates('prod_xyz');
// → [
//     { locale: 'en', slug: 'cheese-pizza' },
//     { locale: 'he', slug: 'פיצה-גבינה' },
//   ]

Render <link rel="alternate" hreflang={locale} href={url} /> from the result for clean hreflang.

Modifier groups in another language

Both the group and its modifiers translate transparently. No special-casing is needed on the render side, and the modifier picker stays the same:

// With setLocale('he') active:
const product = await client.getProductBySlug('pizza');
product.modifierGroups[0].name; // → "תוספות"
product.modifierGroups[0].modifiers[0].name; // → "זיתים"

See Modifiers for the full modifier flow.

Custom fields (metafield definitions)

Custom-field labels, what merchants type as "Warranty Info" or "Pre-order Note", are also translatable. The storefront sees the localized label automatically when fetching products with setLocale():

product.metafields.find((m) => m.key === 'warranty').definition.name;
// → "מידע אחריות" (with locale=he), "Warranty Info" (with locale=en)

The merchant edits these from /products/custom-fields in the dashboard, either via the per-row "Translate" overlay or inside the create/edit dialog (which has an inline "Translate with AI" button on non-default locales).

How merchants populate translations

For background, your integration doesn't need to know this, but it's useful when debugging an unexpectedly-empty translation:

  1. Per-row overlay on every taxonomy/product list page: a quick fix for a single entity.
  2. Inside the entity modal: LocaleSelector in the header swaps the form into translation mode for any non-default locale, with an inline "Translate with AI" button.
  3. Bulk: select N rows on a list page → toolbar action "Translate to Hebrew" enqueues an AI translation job for everything selected (including children, e.g. all modifiers under selected groups).

Managing translations from the API (admin mode)

Translations are persisted via stores/:storeId/translations/... — the same endpoint the dashboard editor above calls. It is reachable with an API key (products:read for reads, products:write for writes — or the equivalent scope for the target entity type), not only from the dashboard, so the storefront SDK does have a route to this in admin mode. This is useful for bulk-importing pre-translated content from a TMS export or automating locale coverage without a human opening the dashboard:

import { BrainerceClient } from 'brainerce';

const client = new BrainerceClient({ apiKey: process.env.BRAINERCE_API_KEY });

// Pre-flight: which entity types/locales still need coverage?
const status = await client.getTranslationStatus(storeId, ['he', 'fr']);

// Read every persisted translation for one entity
const translations = await client.getTranslations(storeId, 'blogPost', postId);

// Write a translation — bulk-import pre-translated content. Only the fields
// valid for the entity type are persisted; this merges into the locale's
// existing fields rather than replacing them.
await client.setTranslation(storeId, 'blogPost', postId, 'fr', {
  title: 'Le titre en français',
  excerpt: "L'extrait en français",
  content: '<p>Le contenu en français</p>',
});

// Or let the AI translate it — inline for one entity, or enqueued in bulk
await client.aiTranslateSingle(storeId, { entityType: 'product', entityId, targetLocale: 'he' });
await client.aiTranslateBulk(storeId, { entityType: 'blogPost', targetLocale: 'fr' });

The storefront (public/customer-mode) SDK still never calls this — it only reads the overlay, exactly as described above. Full method reference: SDK README "Translations Management".

Orders & order-confirmation emails

This is the part integrations most often miss. The locale must be active before checkout starts, because the order freezes it.

  • Call setLocale() before createCheckout(). When the checkout is created, the server captures the active locale onto the order (order.locale). If no locale was sent, order.locale is null.
  • Order line items are frozen in order.locale. Each line's product/variant name is resolved from translations[order.locale] at checkout time and stored on the order. Order history and the confirmation email then show that frozen, localized name everywhere, even months later, immune to later catalog edits. If the product has no translation for that locale, the line falls back to the base name.
  • The confirmation email renders in order.locale (falling back to the customer's saved locale, then the store default). Built-in default templates ship in English and Hebrew. For any other language, create a custom email template per language in the dashboard (Settings → Email templates), or the email falls back to the English built-in, even if the rest of the storefront is localized.

If order emails arrive in the wrong language, the cause is almost always one of: (1) the storefront never called setLocale() before checkout, so order.locale is null and everything follows the store default; (2) the product has no translation for that locale, so the line-item name falls back to the base; or (3) the language has no built-in template and no custom template, so the email body falls back to English. Fix in that order.

// Correct order of operations on a localized storefront:
client.setLocale('he'); // 1. BEFORE any checkout call
const checkout = await client.createCheckout({ cartId }); // 2. captures order.locale = 'he'
// → confirmation email + order history now render in Hebrew

Common pitfalls

SymptomCauseFix
product.name is in English even though setLocale('he') was calledThe store doesn't have he in its supported locales, or he is the store default (overlay only runs for a supported, non-default locale)Add the locale in Settings → Languages (or client.getSupportedLocales() to verify)
Search doesn't find a product by its translated nameNo locale active on the search requestCall setLocale() (or send Accept-Language) before getProducts({ search })
Order confirmation email / order history in the wrong languagesetLocale() wasn't called before createCheckout(), so order.locale is nullSet the locale before checkout. See Orders & order-confirmation emails
Email body is English but the storefront is localizedThat language has no built-in template (only en/he ship) and no custom templateCreate a custom email template for the language in Settings → Email templates
Some fields translate, others stay in the base languageMerchant only translated a subset of fields for that localeField-level fallback is by design; translate the rest in the dashboard
RTL layout broken in HebrewApp not reading client.getStoreDirection() or getDirectionForLocale()Apply dir on <html> or <body> based on the locale
slug is the English one in the Hebrew URLMerchant didn't translate the slug field on ProductThe base slug is the fallback; translate it for clean per-locale URLs
Modifier names don't translate but product name doesMerchant translated Product but not the ModifierGroup/ModifierBulk translate on /products/modifier-groups covers groups + all their modifiers