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 in the API).

Supported languages​

The widget renders UI in the 9 supported locales: es, en, fr, de, it, pt, ca, eu, gl. For unsupported codes, the widget falls back to es 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.27/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 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. Signing is local via HMAC, no calls to Neuroon:

import { createHmac } from 'node:crypto';

const shopByLocale: Record<string, string | undefined> = {
es: process.env.NEUROON_SHOP_ID_ES,
en: process.env.NEUROON_SHOP_ID_EN,
de: process.env.NEUROON_SHOP_ID_DE,
};

function signWidgetToken(shopId: string, apiKey: string): string {
const ts = Math.floor(Date.now() / 1000);
const payload = `${shopId}:${ts}`;
const sig = createHmac('sha256', apiKey).update(payload).digest('hex');
return Buffer.from(`${payload}:${sig}`, 'utf8').toString('base64url');
}

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

For cache + rotation with a 5-minute margin, see Recipe · Server-to-server 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.27/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/integrations/v1/conversions`, {
method: 'POST',
headers: { 'Authorization': 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​