Skip to main content

Recipe: Multi-locale storefront

This guide covers three common scenarios:

  1. Single shop, multiple languages (i18n storefront with a single catalog).
  2. Multiple shops, one per market (different catalog / pricing / stock per country).
  3. Subdomains or sub-paths (/es, /en, de.example.com, etc.).

How the widget detects the locale

The widget applies this cascade (highest priority on top):

  1. data-locale="es" attribute on the <script>.
  2. URL parameter ?lang=es.
  3. <html lang="es"> of the document.
  4. navigator.language from the browser.
  5. Backend default (resolved by LocaleHeaderFilter in the API).

Citation: detection cascade described in Widget · i18n and backed by LocaleHeaderFilter in the backend (see memory project_i18n_auto_detect).

Supported languages

The widget renders UI in at least: es, en, de, fr, it, pt. For unsupported languages, the widget falls back to en and the backend still generates responses in the user's language.

Scenario 1. One shop, multiple languages

If your catalog is single and only the UI changes, set data-locale per render:

<script
src="https://cdn.neuroon.ai/widget@0.9.10/widget.js"
data-token="<%= widgetToken %>"
data-container="#neuroon-search"
data-locale="<%= currentLocale %>" async></script>

Clarifications:

  • The Neuroon catalog supports descriptions / names in a single language per product. If your product has name in Spanish, the widget can translate UI copy on the fly but not the product content.
  • For storefronts with catalogs translated product by product, use scenario 2 with one shop per language.

Scenario 2. Multi-shop per market

When pricing, stock or catalog vary by country, the natural path is one shop per market, each with its own Shop API Key and Widget Token:

MarketshopIdUI localeCatalog
ESshop_es_xxxesEUR prices, ES descriptions
ENshop_en_xxxenEUR/GBP prices, EN descriptions
DEshop_de_xxxdeEUR prices, DE descriptions

Your backend resolves which shopId (and therefore which token) to use on each render:

const shopByLocale = {
es: process.env.NEUROON_SHOP_ID_ES,
en: process.env.NEUROON_SHOP_ID_EN,
de: process.env.NEUROON_SHOP_ID_DE,
};

async function getWidgetTokenForLocale(locale: string) {
const shopId = shopByLocale[locale] ?? shopByLocale.en;
const apiKey = process.env[`NEUROON_API_KEY_${locale.toUpperCase()}`];

const res = await fetch(
`${process.env.NEUROON_API_URL}/api/shops/${shopId}/widget-token`,
{ method: 'POST', headers: { 'X-Shop-API-Key': apiKey } },
);
if (!res.ok) throw new Error('Token issuance failed');
return (await res.json()).token;
}

Each shop is synced separately with its own cron (/products/sync).

Scenario 3. URL routing per locale

Path-based (/es/, /en/)

// Next.js i18n
export default async function Layout({
params: { locale },
children,
}: { params: { locale: string }; children: React.ReactNode }) {
const token = await getWidgetTokenForLocale(locale);
return (
<html lang={locale}>
<body>
{children}
<Script
src="https://cdn.neuroon.ai/widget@0.9.10/widget.js"
strategy="afterInteractive"
data-token={token}
data-container="#neuroon-search"
data-locale={locale}
/>
</body>
</html>
);
}

Sub-domain (es.example.com, de.example.com)

function detectLocale(host: string): string {
const sub = host.split('.')[0];
return ['es', 'en', 'de', 'fr', 'it', 'pt'].includes(sub) ? sub : 'en';
}

Then use detectLocale(req.headers.host) to resolve the locale on every request.

Override via URL parameter

For QA and A/B testing it is useful to force the locale via ?lang=:

// The widget already detects ?lang=es from the URL, no extra code needed.
// To persist the preference:
const url = new URL(window.location);
const lang = url.searchParams.get('lang');
if (lang) localStorage.setItem('neuroon-locale', lang);

Multi-locale cart bridge

The cart bridge does not change between scenarios. The payload only contains product identifiers and aggregates; the currency is defined by the shop, not by the UI locale.

Conversion tracking

In multi-shop, track:conversion must go to the correct shopId:

const shopId = resolveShopFromOrder(order); // same criteria as in the frontend
const apiKey = resolveApiKey(shopId);
await fetch(`${API_URL}/api/plugin/shops/${shopId}/track/conversion`, {
method: 'POST',
headers: { 'X-Shop-API-Key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});

Best practices

  • One Widget Token per shop, not per locale. For scenario 1 (single shop, multiple languages), use the same token and just change data-locale.
  • Cache per shop, not per user. Tokens are per-shop and support thousands of simultaneous impressions.
  • Canonical URL aliases. Verify the canonical domain (example.com or www.example.com) and use the same one across all calls.
  • Pricing: if your product has different prices per market and you use a single shop, the only clean way is to duplicate the product with different externalId (SKU-001-ES, SKU-001-EN). In practice, one shop per market is usually preferable.

Common errors

SymptomCauseFix
The widget starts in en even though the site is in esMissing <html lang> or overridden by a previous scriptSet explicit data-locale="es" in the script.
Results with mixed descriptions (ES + EN)Single catalog with products synced in different languagesNormalize to a single language per product or use multi-shop.
Conversions attributed to the wrong shopBackend uses a global shopId instead of resolving per orderPass shopId from the order context.

Next steps