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": {
"fr": { "name": "Pizza au fromage", "description": "Margherita classique…" },
"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('fr');
const product = await client.getProductBySlug('cheese-pizza');
product.name; // → "Pizza au fromage"
product.description; // → "Margherita classique…"
// 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:
| Entity | Translatable fields |
|---|---|
| Product | name, description, seoTitle, seoDescription, slug |
| ProductVariant | name |
| Category | name, description, metaDescription |
| Brand | name |
| Tag | name |
| Attribute | name (e.g. "Color") |
| AttributeOption | name (e.g. "Red", "Large") |
| ModifierGroup | name, description |
| Modifier | name, description |
| ProductMetafield | value (free-text metafield values) |
| MetafieldDefinition | name, description (custom-field labels) |
| BundleOffer | name, description (the bundle's own marketing label) |
| OrderBumpConfig | title, description (the bump headline shown at checkout) |
| DiscountRule | name, description (merchant-facing rule labels) |
| BlogPost | title, excerpt, content, seoTitle, seoDescription, slug |
| ContactForm | name, description, submitButton, successMessage |
| ContactFormField | label, placeholder, helpText |
| Store | metaDescription |
If an entity isn't in this list, only its base fields will be served.
The entityType string the translation API and tools take for these rows, in table order, is: product, variant, category, brand, tag, attribute, attributeOption, modifierGroup, modifier, metafield, metafieldDefinition, bundleOffer, orderBump, discountRule, blogPost, contactForm, contactFormField, store.
A product's meta description
A product's meta description is translated under the key seoDescription, while the stored default-language text is called metaDescription. When you write a translation, send either name: metaDescription on a product is accepted as seoDescription and saved under that key, so the translated text reaches the second-language page.
On a read, seoDescription is the effective meta description: the translation for the active locale when there is one, otherwise the stored text. It is null only when the merchant wrote none. metaDescription always carries the stored default-language text. Read it with deriveSeoDescription(product), which takes whichever is present and falls back to a stripped description, then the name.
The set_translation MCP tool does not drop a field the entity type does not translate. It refuses the call, saves nothing, and names the accepted fields in the error.
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('fr');
// Or per-call override:
const product = await client.getProductBySlug(slug, { locale: 'fr' });Direct REST
fetch(`${BASE_URL}/products/${id}`, {
headers: { 'Accept-Language': 'fr' },
});The server reads Accept-Language and applies the overlay for that locale. Multi-locale headers like fr, en;q=0.8 resolve to the first locale the store supports. If the store has en, fr configured and the buyer sends fr, en;q=0.8, the server returns French.
A ?locale=fr 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 French finds "Pizza au fromage" by typing the French word "fromage", even though the base name is English:
client.setLocale('fr');
await client.getProducts({ search: 'fromage' }); // matches translations.fr.nameThe 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_…' });
client.setLocale('he');
const dir = client.getStoreDirection(); // → 'rtl' (synchronous)
// Or sync from a known BCP-47 tag without an SDK roundtrip:
import { getDirectionForLocale } from 'brainerce';
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();
// → ['en', 'he', 'ar']It resolves to a plain string[] taken from getStoreInfo(). When the merchant has not switched on Multi-Language it returns only the store's own language (['en']), even if more languages are listed in their settings, because the i18n block of the store info is left out until Enable Multi-Language is on (Settings, the Multi-Language card). Use the list to build a language switcher, and hide the switcher when it has one entry. 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');
// → alternates = [
// { locale: 'en', slug: 'cheese-pizza' },
// { locale: 'fr', slug: 'pizza-au-fromage' },
// ]Render <link rel="alternate" hreflang={locale} href={url} /> from alternates for clean hreflang. The result is an object with an alternates array, not the array itself. getProductAlternates is Store mode only (a client built with storeId); it throws in sales-channel (salesChannelId) mode. See Storefront SDK.
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('fr') active:
const product = await client.getProductBySlug('pizza');
product.modifierGroups[0].name; // → "Garnitures"
product.modifierGroups[0].modifiers[0].name; // → "Champignons"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;
// → "Informations de garantie" (with locale=fr), "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).
Image alt text
Image alt text is localized too, but it is not part of an entity's translations map. Each image keeps its alt text per language (the merchant writes it in the media library, and it is copied onto every product and variant that uses the image). On a read with an active locale, every images[i].alt and every variant image.alt already holds that locale's text, falling back to the default-language alt text when the locale has none, so render alt as it comes:
client.setLocale('fr');
const product = await client.getProductBySlug('cheese-pizza');
product.images[0].alt; // → the French alt text, or the default-language one if none was writtenThe per-language values themselves are not returned to the storefront, only the resolved alt. An image whose product has its own alt text for it keeps that single text in every language. The same overlay applies to the images inside bundle offers, order bumps and product recommendations.
How merchants populate translations
For background, your integration doesn't need to know this, but it's useful when debugging an unexpectedly-empty translation:
- Per-row overlay on every taxonomy/product list page: a quick fix for a single entity.
- Inside the entity modal:
LocaleSelectorin the header swaps the form into translation mode for any non-default locale, with an inline "Translate with AI" button. - 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' });
// aiTranslateBulk resolves to { queued: number } (a job count, the work runs async). Leave `entityIds`
// out to translate every entity of that type that has no complete translation for the locale yet.
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()beforecreateCheckout(). When the checkout is created, the server captures the active locale onto the order (order.locale). If no locale was sent,order.localeisnull. - Order line items are frozen in
order.locale. Each line's product/variant name is resolved fromtranslations[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, soorder.localeis 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('fr'); // 1. BEFORE any checkout call
const checkout = await client.createCheckout({ cartId }); // 2. captures order.locale = 'fr'
// → confirmation email + order history now render in FrenchCommon pitfalls
| Symptom | Cause | Fix |
|---|---|---|
product.name is in English even though setLocale('fr') was called | The store doesn't have fr in its supported locales, or fr is the store default (overlay only runs for a supported, non-default locale) | Add the locale in Settings, Multi-Language card (or client.getSupportedLocales() to verify) |
| Search doesn't find a product by its translated name | No locale active on the search request | Call setLocale() (or send Accept-Language) before getProducts({ search }) |
| Order confirmation email / order history in the wrong language | setLocale() wasn't called before createCheckout(), so order.locale is null | Set the locale before checkout. See Orders & order-confirmation emails |
| Email body is English but the storefront is localized | That language has no built-in template (only en/he ship) and no custom template | Create a custom email template for the language in Settings → Email templates |
| Some fields translate, others stay in the base language | Merchant only translated a subset of fields for that locale | Field-level fallback is by design; translate the rest in the dashboard |
| RTL layout broken in Hebrew | App not reading client.getStoreDirection() or getDirectionForLocale() | Apply dir on <html> or <body> based on the locale |
slug is the English one in the French URL | Merchant didn't translate the slug field on Product | The base slug is the fallback; translate it for clean per-locale URLs |
| Modifier names don't translate but product name does | Merchant translated Product but not the ModifierGroup/Modifier | Bulk translate on /products/modifier-groups covers groups + all their modifiers |